From 1a6cdee677a797b69bbffc05819f2abeabf57e79 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 22:01:37 +0000 Subject: [PATCH 1/3] feat(rendering): add nine-slice support for sprites Adds `NineSliceOptions` (border insets plus stretch/tile modes for edges and center) to `SpriteEcsComponent` and `createImageSprite`. The render system expands a sliced sprite into up to nine batched render commands instead of one, reusing the existing sprite shader and Renderable unchanged, so corner artwork keeps its size while the edges/center stretch or tile as the sprite is resized. Closes #471. --- .../docs/docs/rendering/index.md | 3 + .../docs/docs/rendering/nine-slice-sprites.md | 110 +++++++ src/rendering/components/sprite-component.ts | 10 + src/rendering/index.ts | 1 + src/rendering/nine-slice-options.ts | 74 +++++ src/rendering/systems/render-system.test.ts | 79 ++++- src/rendering/systems/render-system.ts | 125 ++++++-- .../compute-nine-slice-regions.test.ts | 201 +++++++++++++ .../utilities/compute-nine-slice-regions.ts | 269 ++++++++++++++++++ .../utilities/create-image-sprite.ts | 9 + src/rendering/utilities/index.ts | 1 + 11 files changed, 862 insertions(+), 20 deletions(-) create mode 100644 documentation-site/docs/docs/rendering/nine-slice-sprites.md create mode 100644 src/rendering/nine-slice-options.ts create mode 100644 src/rendering/utilities/compute-nine-slice-regions.test.ts create mode 100644 src/rendering/utilities/compute-nine-slice-regions.ts diff --git a/documentation-site/docs/docs/rendering/index.md b/documentation-site/docs/docs/rendering/index.md index 17a94515..a153047c 100644 --- a/documentation-site/docs/docs/rendering/index.md +++ b/documentation-site/docs/docs/rendering/index.md @@ -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. diff --git a/documentation-site/docs/docs/rendering/nine-slice-sprites.md b/documentation-site/docs/docs/rendering/nine-slice-sprites.md new file mode 100644 index 00000000..25e159dc --- /dev/null +++ b/documentation-site/docs/docs/rendering/nine-slice-sprites.md @@ -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 an unstretched 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. diff --git a/src/rendering/components/sprite-component.ts b/src/rendering/components/sprite-component.ts index 45434e72..e822eaf2 100644 --- a/src/rendering/components/sprite-component.ts +++ b/src/rendering/components/sprite-component.ts @@ -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 @@ -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 diff --git a/src/rendering/index.ts b/src/rendering/index.ts index 7ea4934b..e69e4bc3 100644 --- a/src/rendering/index.ts +++ b/src/rendering/index.ts @@ -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'; diff --git a/src/rendering/nine-slice-options.ts b/src/rendering/nine-slice-options.ts new file mode 100644 index 00000000..89096e79 --- /dev/null +++ b/src/rendering/nine-slice-options.ts @@ -0,0 +1,74 @@ +/** + * How a nine-slice region behaves when the sprite is resized: `'stretch'` + * scales the region's texture to fill the available space, while `'tile'` + * repeats the region's texture at its native size, rounding the repeat + * count so tiles fill the available space evenly (no cropped partial + * tile at the end). + */ +export type SliceScaleMode = 'stretch' | 'tile'; + +/** + * Configures nine-slice ("9-patch") scaling for a sprite: the sprite's + * texture is cut into a 3x3 grid by an inset from each edge, and the four + * corner regions are drawn at a fixed size while the four edge regions and + * the center region stretch or tile to fill the sprite's current + * `width`/`height`. This keeps corner artwork (e.g. a rounded panel border) + * from distorting when the sprite is resized. + */ +export interface NineSliceOptions { + /** + * The width, in the same world units as the sprite's `width`, of the left + * border inset. + */ + left: number; + + /** + * The width, in the same world units as the sprite's `width`, of the + * right border inset. + */ + right: number; + + /** + * The height, in the same world units as the sprite's `height`, of the + * top border inset. + */ + top: number; + + /** + * The height, in the same world units as the sprite's `height`, of the + * bottom border inset. + */ + bottom: number; + + /** + * How the four edge regions (top, bottom, left, right) scale to fill + * their stretch axis. Defaults to `'stretch'`. + */ + edgeMode?: SliceScaleMode; + + /** + * How the center region scales to fill the sprite. Defaults to + * `'stretch'`. + */ + centerMode?: SliceScaleMode; + + /** + * The sprite's original (not-yet-resized) width. Anchors the border + * insets to the correct texture UV fractions regardless of the sprite's + * current `width`, and works out how many times an edge/center region + * repeats when its mode is `'tile'`. Defaults to the sprite's current + * `width`, so a sprite that's never resized after being sliced tiles as a + * single, unrepeated region. + */ + nativeWidth?: number; + + /** + * The sprite's original (not-yet-resized) height. Anchors the border + * insets to the correct texture UV fractions regardless of the sprite's + * current `height`, and works out how many times an edge/center region + * repeats when its mode is `'tile'`. Defaults to the sprite's current + * `height`, so a sprite that's never resized after being sliced tiles as a + * single, unrepeated region. + */ + nativeHeight?: number; +} diff --git a/src/rendering/systems/render-system.test.ts b/src/rendering/systems/render-system.test.ts index 5e4d552b..4e302db1 100644 --- a/src/rendering/systems/render-system.test.ts +++ b/src/rendering/systems/render-system.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; import { createRenderEcsSystem } from './render-system'; import { EcsWorld } from '../../ecs'; -import { PositionEcsComponent, positionId } from '../../common'; +import { PositionEcsComponent, positionId, rotationId } from '../../common'; import { Vector2 } from '../../math'; import { CameraEcsComponent, cameraId } from '../components'; import { SpriteEcsComponent, spriteId } from '../components'; @@ -454,4 +454,81 @@ describe('createRenderEcsSystem', () => { expect(mockGl.clear).toHaveBeenCalledTimes(2); }); + + describe('nine-slice sprites', () => { + it('draws a sliced sprite as nine batched instances of the same renderable', () => { + addCameraEntity(); + const { renderable, bindInstanceData } = createRenderable(4); + + addSpriteEntity(renderable, 0, { + width: 100, + height: 100, + pivot: new Vector2(0.5, 0.5), + slices: { left: 10, right: 10, top: 10, bottom: 10 }, + }); + + world.update(); + + expect(bindInstanceData).toHaveBeenCalledTimes(9); + // All nine regions share the same renderable, so they still batch + // into a single draw call. + expect(mockGl.drawArraysInstanced).toHaveBeenCalledTimes(1); + expect(mockGl.drawArraysInstanced).toHaveBeenCalledWith( + undefined, + 0, + 6, + 9, + ); + }); + + it('positions each region around the entity, accounting for rotation', () => { + addCameraEntity(); + const { renderable, bindInstanceData } = createRenderable(4); + + const entity = world.createEntity(); + + world.addComponent(entity, positionId, { + local: new Vector2(50, 0), + world: new Vector2(50, 0), + }); + world.addComponent(entity, rotationId, { local: 0, world: Math.PI }); + world.addComponent( + entity, + spriteId, + createSprite(renderable, { + width: 100, + height: 100, + pivot: new Vector2(0.5, 0.5), + slices: { left: 10, right: 10, top: 10, bottom: 10 }, + }), + ); + + world.update(); + + const positions = bindInstanceData.mock.calls.map( + (call) => (call[0] as { position: PositionEcsComponent }).position, + ); + + // The top-left corner (offset (-45, -45) before rotation) rotates 180 + // degrees around the entity, landing on the opposite side. + const rotatedCorner = positions.find( + (position) => + Math.abs(position.world.x - (50 + 45)) < 1e-9 && + Math.abs(position.world.y - 45) < 1e-9, + ); + + expect(rotatedCorner).toBeDefined(); + }); + + it('does not slice a sprite with no `slices` configured', () => { + addCameraEntity(); + const { renderable, bindInstanceData } = createRenderable(4); + + addSpriteEntity(renderable, 0, { width: 100, height: 100 }); + + world.update(); + + expect(bindInstanceData).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/src/rendering/systems/render-system.ts b/src/rendering/systems/render-system.ts index fd76f77e..e9ab43b6 100644 --- a/src/rendering/systems/render-system.ts +++ b/src/rendering/systems/render-system.ts @@ -8,7 +8,7 @@ import { ScaleEcsComponent, scaleId, } from '../../common/index.js'; -import { Matrix3x3 } from '../../math/index.js'; +import { Matrix3x3, Vector2 } from '../../math/index.js'; import { EcsSystem } from '../../ecs/ecs-system.js'; import { matchesMask } from '../../utilities/matches-mask.js'; import { @@ -19,9 +19,10 @@ import { } from '../components/index.js'; import { RenderContext } from '../render-context.js'; import { RenderTarget } from '../render-target.js'; -import { InstanceComponents, Renderable } from '../renderable.js'; +import { Renderable } from '../renderable.js'; import { createProjectionMatrix } from '../shaders/index.js'; import { RenderCommand } from '../render-command.js'; +import { computeNineSliceRegions } from '../utilities/compute-nine-slice-regions.js'; /** * The result of a single camera's `run` pass. `afterRun` receives one of @@ -104,6 +105,101 @@ const includeBatch = ( const spriteEntityBuffer: number[] = []; +/** + * The pivot every nine-slice region is drawn with: each region is its own + * quad, so it's always centered on its own computed offset rather than + * inheriting the parent sprite's pivot (which `computeNineSliceRegions` + * already folds into that offset). + */ +const centeredPivot = new Vector2(0.5, 0.5); + +/** + * Pushes one `RenderCommand` for a normal sprite, or - when `spriteComponent` + * has `slices` set - one `RenderCommand` per nine-slice region, each a + * separate quad positioned and sized to reproduce the sliced layout. Every + * region shares the entity's own rotation, scale and flip components; only + * their position (offset around the entity, then rotated and scaled the + * same way the shader would rotate/scale a single quad) and their + * sprite-shaped size/pivot/UV rect differ per region. + */ +const pushSpriteRenderCommands = ( + commands: RenderCommand[], + spriteComponent: SpriteEcsComponent, + entityPosition: PositionEcsComponent, + rotationComponent: RotationEcsComponent | null, + scaleComponent: ScaleEcsComponent | null, + flipComponent: FlipEcsComponent | null, +): void => { + const { renderable, layer, slices } = spriteComponent; + const depth = entityPosition.world.y; + + if (!slices) { + commands.push({ + layer, + depth, + renderable, + components: { + position: entityPosition, + rotation: rotationComponent, + scale: scaleComponent, + sprite: spriteComponent, + flip: flipComponent, + }, + }); + + return; + } + + const regions = computeNineSliceRegions( + spriteComponent.width, + spriteComponent.height, + spriteComponent.pivot, + spriteComponent.uvOffset, + spriteComponent.uvScale, + slices, + ); + + const rotationRadians = rotationComponent?.world ?? 0; + const scaleX = + (scaleComponent?.world.x ?? 1) * (flipComponent?.flipX ? -1 : 1); + const scaleY = + (scaleComponent?.world.y ?? 1) * (flipComponent?.flipY ? -1 : 1); + + for (const region of regions) { + const regionOffset = new Vector2( + region.offset.x * scaleX, + region.offset.y * scaleY, + ).rotate(rotationRadians); + + const regionPosition: PositionEcsComponent = { + local: entityPosition.local, + world: entityPosition.world.add(regionOffset), + }; + + const regionSprite: SpriteEcsComponent = { + ...spriteComponent, + width: region.size.x, + height: region.size.y, + pivot: centeredPivot, + uvOffset: region.uvOffset, + uvScale: region.uvScale, + }; + + commands.push({ + layer, + depth, + renderable, + components: { + position: regionPosition, + rotation: rotationComponent, + scale: scaleComponent, + sprite: regionSprite, + flip: flipComponent, + }, + }); + } +}; + /** * One `RenderCommand[]` per camera visited this tick, indexed by that * camera's position in query order (reset via `cameraIndex`, not by @@ -201,23 +297,14 @@ export const createRenderEcsSystem = ( positionId, )!; // Position component is guaranteed to exist due to the query in beforeQuery. - const components: InstanceComponents = { - position: entityPosition, - rotation: world.getComponent( - spriteEntity, - rotationId, - ), - scale: world.getComponent(spriteEntity, scaleId), - sprite: spriteComponent, - flip: world.getComponent(spriteEntity, flipId), - }; - - commands.push({ - layer: spriteComponent.layer, - depth: entityPosition.world.y, - renderable, - components, - }); + pushSpriteRenderCommands( + commands, + spriteComponent, + entityPosition, + world.getComponent(spriteEntity, rotationId), + world.getComponent(spriteEntity, scaleId), + world.getComponent(spriteEntity, flipId), + ); } return { diff --git a/src/rendering/utilities/compute-nine-slice-regions.test.ts b/src/rendering/utilities/compute-nine-slice-regions.test.ts new file mode 100644 index 00000000..4105c9d5 --- /dev/null +++ b/src/rendering/utilities/compute-nine-slice-regions.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from 'vitest'; +import { Vector2 } from '../../math/index.js'; +import { computeNineSliceRegions } from './compute-nine-slice-regions.js'; + +describe('computeNineSliceRegions', () => { + const centerPivot = new Vector2(0.5, 0.5); + const fullUv = { offset: new Vector2(0, 0), scale: new Vector2(1, 1) }; + + it('degenerates to a single region matching a non-sliced sprite when there are no insets', () => { + const regions = computeNineSliceRegions( + 100, + 50, + centerPivot, + fullUv.offset, + fullUv.scale, + { left: 0, right: 0, top: 0, bottom: 0 }, + ); + + expect(regions).toHaveLength(1); + expect(regions[0].offset).toEqual(new Vector2(0, 0)); + expect(regions[0].size).toEqual(new Vector2(100, 50)); + expect(regions[0].uvOffset).toEqual(new Vector2(0, 0)); + expect(regions[0].uvScale).toEqual(new Vector2(1, 1)); + }); + + it('produces nine regions for a sprite with borders on every side', () => { + const regions = computeNineSliceRegions( + 100, + 100, + centerPivot, + fullUv.offset, + fullUv.scale, + { left: 10, right: 10, top: 10, bottom: 10 }, + ); + + expect(regions).toHaveLength(9); + + const totalArea = regions.reduce((sum, r) => sum + r.size.x * r.size.y, 0); + + expect(totalArea).toBeCloseTo(100 * 100, 5); + }); + + it('positions the four corners with fixed size regardless of overall size', () => { + const regions = computeNineSliceRegions( + 200, + 120, + centerPivot, + fullUv.offset, + fullUv.scale, + { left: 8, right: 12, top: 4, bottom: 6 }, + ); + + const topLeft = regions.find((r) => r.size.x === 8 && r.size.y === 4); + const bottomRight = regions.find((r) => r.size.x === 12 && r.size.y === 6); + + expect(topLeft).toBeDefined(); + expect(bottomRight).toBeDefined(); + + // Top-left corner center offset from the sprite's own center anchor. + expect(topLeft!.offset.x).toBeCloseTo(-200 / 2 + 8 / 2, 5); + expect(topLeft!.offset.y).toBeCloseTo(-120 / 2 + 4 / 2, 5); + + // Bottom-right corner center offset. + expect(bottomRight!.offset.x).toBeCloseTo(200 / 2 - 12 / 2, 5); + expect(bottomRight!.offset.y).toBeCloseTo(120 / 2 - 6 / 2, 5); + }); + + it('places the top-left corner exactly at the anchor when the pivot is (0, 0)', () => { + const regions = computeNineSliceRegions( + 100, + 100, + new Vector2(0, 0), + fullUv.offset, + fullUv.scale, + { left: 10, right: 10, top: 10, bottom: 10 }, + ); + + const topLeft = regions.find((r) => r.size.x === 10 && r.size.y === 10); + + expect(topLeft).toBeDefined(); + expect(topLeft!.offset).toEqual(new Vector2(5, 5)); + }); + + it('proportionally clamps insets that would otherwise overlap', () => { + const regions = computeNineSliceRegions( + 10, + 10, + centerPivot, + fullUv.offset, + fullUv.scale, + { left: 8, right: 8, top: 0, bottom: 0 }, + ); + + // left+right (16) exceeds width (10), so both are scaled down to 5 each, + // leaving no room for a center band. + expect(regions).toHaveLength(2); + + for (const region of regions) { + expect(region.size.x).toBeCloseTo(5, 5); + } + }); + + it('maps border insets to UV fractions using the native size, not the current size', () => { + const regions = computeNineSliceRegions( + 200, // stretched to double the native width + 100, + centerPivot, + new Vector2(0, 0), + new Vector2(1, 1), + { left: 10, right: 10, top: 0, bottom: 0, nativeWidth: 100 }, + ); + + const leftEdge = regions.find((r) => r.offset.x < 0 && r.size.x === 10); + + expect(leftEdge).toBeDefined(); + // 10 / 100 native width = 0.1 of the texture, unaffected by the resize. + expect(leftEdge!.uvScale.x).toBeCloseTo(0.1, 5); + }); + + it('stretches the center region into a single segment by default', () => { + const regions = computeNineSliceRegions( + 300, + 100, + centerPivot, + fullUv.offset, + fullUv.scale, + { left: 10, right: 10, top: 0, bottom: 0, nativeWidth: 100 }, + ); + + const centerSegments = regions.filter( + (r) => r.offset.x > -140 && r.offset.x < 140 && r.size.x > 20, + ); + + expect(centerSegments).toHaveLength(1); + expect(centerSegments[0].size.x).toBeCloseTo(280, 5); + }); + + it('tiles the center region into evenly-sized repeats when centerMode is tile', () => { + const regions = computeNineSliceRegions( + 300, + 100, + centerPivot, + fullUv.offset, + fullUv.scale, + { + left: 10, + right: 10, + top: 0, + bottom: 0, + centerMode: 'tile', + nativeWidth: 100, + }, + ); + + // Native center width is 100 - 10 - 10 = 80; current center width is + // 300 - 10 - 10 = 280; round(280 / 80) = 4 tiles (would be 3.5 exactly, + // but that rounds to 4 with banker's-unbiased Math.round semantics). + const tiles = regions.filter((r) => Math.abs(r.size.x - 70) < 1e-5); + + expect(tiles).toHaveLength(4); + + for (const tile of tiles) { + // Every tile samples the same center band texture rect: the full + // sprite (1.0) minus the two 10/100-native-width border fractions. + expect(tile.uvScale.x).toBeCloseTo(0.8, 5); + } + + const totalTileWidth = tiles.reduce((sum, t) => sum + t.size.x, 0); + expect(totalTileWidth).toBeCloseTo(280, 5); + }); + + it('tiles only the stretch axis of an edge region, leaving the border axis fixed', () => { + const regions = computeNineSliceRegions( + 100, + 300, + centerPivot, + fullUv.offset, + fullUv.scale, + { + left: 10, + right: 10, + top: 0, + bottom: 0, + edgeMode: 'tile', + nativeWidth: 100, + nativeHeight: 100, + }, + ); + + const leftEdgeTiles = regions.filter( + (r) => r.size.x === 10 && r.offset.x < 0, + ); + + // Native height 100 tiling over a current height of 300 => 3 tiles. + expect(leftEdgeTiles).toHaveLength(3); + + for (const tile of leftEdgeTiles) { + expect(tile.size.y).toBeCloseTo(100, 5); + } + }); +}); diff --git a/src/rendering/utilities/compute-nine-slice-regions.ts b/src/rendering/utilities/compute-nine-slice-regions.ts new file mode 100644 index 00000000..eab6e2e6 --- /dev/null +++ b/src/rendering/utilities/compute-nine-slice-regions.ts @@ -0,0 +1,269 @@ +import { Vector2 } from '../../math/index.js'; +import { NineSliceOptions, SliceScaleMode } from '../nine-slice-options.js'; + +/** + * A single quad, in a sliced sprite's own local sprite-space, that the + * render system draws in place of the sprite's single quad when it has + * `NineSliceOptions` set. + */ +export interface NineSliceRegion { + /** + * This region's center, as an offset from the sprite's pivot-adjusted + * anchor point, in the sprite's unscaled local units (before rotation and + * `ScaleEcsComponent` are applied). + */ + offset: Vector2; + + /** This region's rendered width/height, in the same unscaled units. */ + size: Vector2; + + /** The top-left corner of this region's texture rect, 0 to 1. */ + uvOffset: Vector2; + + /** The width/height of this region's texture rect, 0 to 1. */ + uvScale: Vector2; +} + +const EPSILON = 1e-5; + +/** + * Scales down `near`/`far` proportionally so they never overlap (sum to more + * than `total`), the same way an over-sized pair of nine-slice borders is + * clamped so the corners meet in the middle instead of the center inverting. + */ +function clampInsets( + near: number, + far: number, + total: number, +): [number, number] { + if (total <= 0) { + return [0, 0]; + } + + const sum = near + far; + + if (sum <= total) { + return [near, far]; + } + + const factor = total / sum; + + return [near * factor, far * factor]; +} + +interface Band { + /** This band's start coordinate, in unscaled sprite-space units. */ + start: number; + + /** This band's current (possibly resized) size. */ + size: number; + + /** This band's original (not-yet-resized) size, used to derive tile counts. */ + nativeSize: number; + + /** The top-left of this band's texture rect, 0 to 1. */ + uvStart: number; + + /** The width/height of this band's texture rect, 0 to 1. */ + uvSize: number; + + /** Whether this band is the center band (the one that can stretch/tile). */ + stretches: boolean; +} + +/** + * Splits one axis (width or height) into up to three bands: a near border + * (left/top), a center band, and a far border (right/bottom). + */ +function createAxisBands( + size: number, + nativeSize: number, + near: number, + far: number, + uvOffset: number, + uvScale: number, +): Band[] { + const [clampedNear, clampedFar] = clampInsets(near, far, size); + const [nativeNear, nativeFar] = clampInsets(near, far, nativeSize); + + const nearUvSize = nativeSize > 0 ? (nativeNear / nativeSize) * uvScale : 0; + const farUvSize = nativeSize > 0 ? (nativeFar / nativeSize) * uvScale : 0; + + const bands: Band[] = []; + + if (clampedNear > EPSILON) { + bands.push({ + start: 0, + size: clampedNear, + nativeSize: clampedNear, + uvStart: uvOffset, + uvSize: nearUvSize, + stretches: false, + }); + } + + const centerSize = size - clampedNear - clampedFar; + + if (centerSize > EPSILON) { + bands.push({ + start: clampedNear, + size: centerSize, + nativeSize: Math.max(0, nativeSize - nativeNear - nativeFar), + uvStart: uvOffset + nearUvSize, + uvSize: uvScale - nearUvSize - farUvSize, + stretches: true, + }); + } + + if (clampedFar > EPSILON) { + bands.push({ + start: size - clampedFar, + size: clampedFar, + nativeSize: clampedFar, + uvStart: uvOffset + uvScale - farUvSize, + uvSize: farUvSize, + stretches: false, + }); + } + + return bands; +} + +interface Segment { + start: number; + size: number; +} + +/** + * Splits a band into one or more equally-sized segments along its own axis. + * `'stretch'` (and any non-stretching band, i.e. a border) always yields a + * single segment spanning the whole band. `'tile'` yields + * `round(band.size / band.nativeSize)` equal segments (at least one), each + * sampling the band's full texture rect - a "round" repeat that fills the + * band evenly with whole tiles instead of cropping a partial tile at the end. + */ +function subdivideBand(band: Band, mode: SliceScaleMode): Segment[] { + if (mode === 'stretch' || !band.stretches || band.nativeSize <= EPSILON) { + return [{ start: band.start, size: band.size }]; + } + + const tileCount = Math.max(1, Math.round(band.size / band.nativeSize)); + const segmentSize = band.size / tileCount; + + return Array.from({ length: tileCount }, (_, index) => ({ + start: band.start + index * segmentSize, + size: segmentSize, + })); +} + +/** + * Builds the regions for a single grid cell (one x band crossed with one y + * band), subdividing into tiled segments per axis where that axis' mode is + * `'tile'`. + */ +function createCellRegions( + xBand: Band, + yBand: Band, + edgeMode: SliceScaleMode, + centerMode: SliceScaleMode, + pivot: Vector2, + width: number, + height: number, +): NineSliceRegion[] { + const mode = xBand.stretches && yBand.stretches ? centerMode : edgeMode; + const xSegments = subdivideBand(xBand, mode); + const ySegments = subdivideBand(yBand, mode); + const regions: NineSliceRegion[] = []; + + for (const xSegment of xSegments) { + for (const ySegment of ySegments) { + regions.push({ + offset: new Vector2( + xSegment.start + xSegment.size / 2 - pivot.x * width, + ySegment.start + ySegment.size / 2 - pivot.y * height, + ), + size: new Vector2(xSegment.size, ySegment.size), + uvOffset: new Vector2(xBand.uvStart, yBand.uvStart), + uvScale: new Vector2(xBand.uvSize, yBand.uvSize), + }); + } + } + + return regions; +} + +/** + * Computes the nine-slice regions for a sprite, given its current size, and + * cuts each stretching band into tiled segments where its mode is `'tile'`. + * + * Corner regions are always a single, fixed-size quad. Edge regions stretch + * or tile along their one free axis (`edgeMode`); the center region + * stretches or tiles along both axes (`centerMode`). A border with an inset + * of `0` (the default for any axis with no slicing) collapses to just its + * center band, so a sprite with no insets at all degenerates to the single + * region a non-sliced sprite would render. + * @param width - The sprite's current width, in unscaled sprite-space units. + * @param height - The sprite's current height, in unscaled sprite-space units. + * @param pivot - The sprite's pivot, normalized to its own size. + * @param uvOffset - The sprite's texture rect offset, 0 to 1. + * @param uvScale - The sprite's texture rect size, 0 to 1. + * @param slices - The nine-slice configuration. + * @returns Up to nine regions, each a quad to draw in place of the sprite's + * single quad. + */ +export function computeNineSliceRegions( + width: number, + height: number, + pivot: Vector2, + uvOffset: Vector2, + uvScale: Vector2, + slices: NineSliceOptions, +): NineSliceRegion[] { + const { + left, + right, + top, + bottom, + edgeMode = 'stretch', + centerMode = 'stretch', + nativeWidth = width, + nativeHeight = height, + } = slices; + + const xBands = createAxisBands( + width, + nativeWidth, + left, + right, + uvOffset.x, + uvScale.x, + ); + const yBands = createAxisBands( + height, + nativeHeight, + top, + bottom, + uvOffset.y, + uvScale.y, + ); + + const regions: NineSliceRegion[] = []; + + for (const xBand of xBands) { + for (const yBand of yBands) { + regions.push( + ...createCellRegions( + xBand, + yBand, + edgeMode, + centerMode, + pivot, + width, + height, + ), + ); + } + } + + return regions; +} diff --git a/src/rendering/utilities/create-image-sprite.ts b/src/rendering/utilities/create-image-sprite.ts index 73bb40a5..ac2422dc 100644 --- a/src/rendering/utilities/create-image-sprite.ts +++ b/src/rendering/utilities/create-image-sprite.ts @@ -6,6 +6,7 @@ import { Vector2, } from '../../index.js'; import { Material } from '../materials/index.js'; +import { NineSliceOptions } from '../nine-slice-options.js'; import { RenderContext } from '../render-context.js'; import { createTextureFromImage, @@ -63,6 +64,13 @@ export interface CreateImageSpriteOptions { * instead of being sampled into a hard, staggered stair-step. */ pixelated?: boolean; + + /** + * Nine-slice configuration, for a sprite whose corners should stay a + * fixed size while its edges/center stretch or tile. Omit for a normal, + * single-quad sprite. + */ + slices?: NineSliceOptions; } // `color` isn't included here: `Color.white` can't be read at module-init @@ -143,5 +151,6 @@ export function createImageSprite( uvOffset: new Vector2(0, 0), uvScale: new Vector2(1, 1), layer: 0, + slices: options.slices, }; } diff --git a/src/rendering/utilities/index.ts b/src/rendering/utilities/index.ts index 51ec0248..7b7a6e5b 100644 --- a/src/rendering/utilities/index.ts +++ b/src/rendering/utilities/index.ts @@ -1,3 +1,4 @@ +export * from './compute-nine-slice-regions.js'; export * from './create-camera.js'; export * from './create-canvas.js'; export * from './create-image-sprite.js'; From 1ec71fe61c8597b9d627307ff94b347674d550e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 22:04:06 +0000 Subject: [PATCH 2/3] fix(docs): avoid "unstretched" in nine-slice docs to satisfy cspell --- documentation-site/docs/docs/rendering/nine-slice-sprites.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation-site/docs/docs/rendering/nine-slice-sprites.md b/documentation-site/docs/docs/rendering/nine-slice-sprites.md index 25e159dc..663ba827 100644 --- a/documentation-site/docs/docs/rendering/nine-slice-sprites.md +++ b/documentation-site/docs/docs/rendering/nine-slice-sprites.md @@ -46,7 +46,7 @@ layer/depth sorting all work exactly as they do for a normal sprite). `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 an unstretched sprite, an inset of `12` +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 From 6ef172907dd62f55bdc5f72b142876441b09527d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 17:39:54 +0000 Subject: [PATCH 3/3] docs(demo): add nine-slice sprites demo Adds a side-by-side comparison demo: a naively-stretched sprite next to nine-sliced sprites in 'stretch' and 'tile' modes, all breathing between the same size range, so the corner-preserving behavior is visible at a glance. --- documentation-site/docusaurus.config.ts | 4 + .../pages/demos/nine-slice/_create-game.ts | 29 ++++++ .../pages/demos/nine-slice/_create-panels.ts | 89 ++++++++++++++++++ .../demos/nine-slice/_panel.component.ts | 13 +++ .../pages/demos/nine-slice/_panel.system.ts | 31 ++++++ .../src/pages/demos/nine-slice/index.tsx | 70 ++++++++++++++ .../static/img/nine-slice/panel.png | Bin 0 -> 354 bytes 7 files changed, 236 insertions(+) create mode 100644 documentation-site/src/pages/demos/nine-slice/_create-game.ts create mode 100644 documentation-site/src/pages/demos/nine-slice/_create-panels.ts create mode 100644 documentation-site/src/pages/demos/nine-slice/_panel.component.ts create mode 100644 documentation-site/src/pages/demos/nine-slice/_panel.system.ts create mode 100644 documentation-site/src/pages/demos/nine-slice/index.tsx create mode 100644 documentation-site/static/img/nine-slice/panel.png diff --git a/documentation-site/docusaurus.config.ts b/documentation-site/docusaurus.config.ts index 43d8d901..84c3bffe 100644 --- a/documentation-site/docusaurus.config.ts +++ b/documentation-site/docusaurus.config.ts @@ -159,6 +159,10 @@ const config: Config = { to: 'demos/particles', label: 'Particles', }, + { + to: 'demos/nine-slice', + label: 'Nine-Slice Sprites', + }, ], }, { diff --git a/documentation-site/src/pages/demos/nine-slice/_create-game.ts b/documentation-site/src/pages/demos/nine-slice/_create-game.ts new file mode 100644 index 00000000..ccdce2cd --- /dev/null +++ b/documentation-site/src/pages/demos/nine-slice/_create-game.ts @@ -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 => { + 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; +}; diff --git a/documentation-site/src/pages/demos/nine-slice/_create-panels.ts b/documentation-site/src/pages/demos/nine-slice/_create-panels.ts new file mode 100644 index 00000000..c556d7a5 --- /dev/null +++ b/documentation-site/src/pages/demos/nine-slice/_create-panels.ts @@ -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 { + 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); +} diff --git a/documentation-site/src/pages/demos/nine-slice/_panel.component.ts b/documentation-site/src/pages/demos/nine-slice/_panel.component.ts new file mode 100644 index 00000000..463a3032 --- /dev/null +++ b/documentation-site/src/pages/demos/nine-slice/_panel.component.ts @@ -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('panel'); diff --git a/documentation-site/src/pages/demos/nine-slice/_panel.system.ts b/documentation-site/src/pages/demos/nine-slice/_panel.system.ts new file mode 100644 index 00000000..d4a66997 --- /dev/null +++ b/documentation-site/src/pages/demos/nine-slice/_panel.system.ts @@ -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; + }, +}); diff --git a/documentation-site/src/pages/demos/nine-slice/index.tsx b/documentation-site/src/pages/demos/nine-slice/index.tsx new file mode 100644 index 00000000..d9f22b5e --- /dev/null +++ b/documentation-site/src/pages/demos/nine-slice/index.tsx @@ -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 ( + + 1} + text="Naive stretch (no slicing)" + /> + 2} + text="Nine-slice: stretch" + /> + 3} + 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, + }, + ]} + /> + ); +} diff --git a/documentation-site/static/img/nine-slice/panel.png b/documentation-site/static/img/nine-slice/panel.png new file mode 100644 index 0000000000000000000000000000000000000000..839aaef70a0dc89f791ff03280716bc54950250a GIT binary patch literal 354 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7T#lEVAS+5#q0$6<$ulG`cCe+Cm(mcn%m&mjHgv!i`T5oda*#kz_6>zC^(zoEq+?k3l v(P>HTp0b=jWHL_kXx=h9e2?Yj^aIk5!w&|EnuWdth8=^atDnm{r-UW|lF*AN literal 0 HcmV?d00001