# @yagejs/renderer

Depends on `@yagejs/core`, `pixi.js`. PixiJS v8 rendering behind the YAGE plugin interface.

## Type vocabulary

Every exported field, parameter, and return type across `@yagejs/renderer` (and its downstream consumers — `@yagejs/ui`, `@yagejs/particles`, `@yagejs/tilemap`) uses this package's own alias names instead of a direct `pixi.js` type import, so consumer code never has to import `pixi.js` for types:

| Alias                    | Underlying Pixi type                                     | Where it shows up                                                                              |
| ------------------------ | -------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `DisplayContainer`       | `Container`                                              | `renderObject` getters, `RenderLayer.container`, UI element `container`/`displayObject` fields |
| `DisplaySprite`          | `Sprite`                                                 | `SpriteComponent.sprite`                                                                       |
| `DisplayAnimatedSprite`  | `AnimatedSprite`                                         | `AnimatedSpriteComponent.animatedSprite`                                                       |
| `DisplayText`            | `Text`                                                   | `TextComponent.text` (canvas variant)                                                          |
| `DisplayBitmapText`      | `BitmapText`                                             | `TextComponent.text` (bitmap variant)                                                          |
| `DisplaySplitText`       | `SplitText`                                              | `SplitTextComponent.splitText` (canvas variant)                                                |
| `DisplaySplitBitmapText` | `SplitBitmapText`                                        | `SplitTextComponent.splitText` (bitmap variant)                                                |
| `GraphicsContext`        | `Graphics`                                               | `GraphicsComponent.graphics`, `draw((g) => ...)`, mask draw callbacks                          |
| `NineSliceSprite`        | `NineSliceSprite`                                        | `createNineSlice()` return, `UINineSlice.container`                                            |
| `ParticleContainer`      | `ParticleContainer`                                      | `@yagejs/particles`' `ParticleEmitterComponent.container`                                      |
| `Particle`               | `Particle`                                               | `@yagejs/particles`' `ParticlePool.acquire()`/`release()`                                      |
| `Filter`                 | `Filter`                                                 | effects API (`Effect.filter`, `rawFilter(filter)`)                                             |
| `Application`            | `Application`                                            | `RendererPlugin.application`                                                                   |
| `ApplicationOptions`     | `ApplicationOptions`                                     | `RendererConfig.pixi`                                                                          |
| `DestroyOptions`         | `DestroyOptions`                                         | visual components' `destroyOptions()` override hook                                            |
| `ColorValue`             | `ColorSource`                                            | every `tint` option/accessor                                                                   |
| `BlendMode`              | `BLEND_MODES`                                            | every visual component's `blendMode` option/accessor                                           |
| `PointLike`              | `PointData`                                              | point-shaped callbacks/options                                                                 |
| `TextStyle`              | `TextStyleOptions`                                       | every `style` option                                                                           |
| `TextureRef`             | none — `string \| TextureHandle`, a key/handle reference | `SpriteComponent.texture`, `setTexture()`                                                      |

The aliases are transparent (`type DisplayContainer = Container`) and provide no encapsulation. Escape hatches (`RendererPlugin.application`, every `renderObject` getter, `.sprite`/`.graphics`/`.text`/`.splitText`/`.animatedSprite`) still return the real Pixi object. Only the _type_ used to describe it is aliased, so calling any native Pixi method on it works exactly as it would on the raw type.

A `GraphicsContext` therefore carries Pixi's graphics transform stack —
`translateTransform`, `scaleTransform`, `rotateTransform`, `setTransform`,
`resetTransform`, and the `save` / `restore` pair. See `GraphicsComponent`
below.

## Setup

```ts yage-context="engine"
import { RendererPlugin } from "@yagejs/renderer";

engine.use(
  new RendererPlugin({
    width: 800,
    height: 600,
    backgroundColor: 0x1a1a2e,
    container: document.getElementById("game")!,
    // optional:
    virtualWidth: 320, // virtual resolution (auto-scaled)
    virtualHeight: 240,
    resolution: window.devicePixelRatio,
    fit: { mode: "cover" }, // override default letterbox (see below)
    pixelArtPreset: true, // crisp, non-blurred pixel art (see below)
  }),
);
```

### `pixelArtPreset`

One flag for pixel-art games. When `true`, the plugin:

- Sets `TextureStyle.defaultOptions.scaleMode = "nearest"` before `Application.init` so textures loaded by `Assets` sample without bilinear blur.
- Passes `roundPixels: true` into the Pixi `Application` so subpixel transforms don't smear sprite edges.
- Writes `image-rendering: -webkit-optimize-contrast; image-rendering: pixelated;` onto the canvas `style.cssText` so the browser scales the backing store with nearest-neighbor. The Safari fallback is the first declaration; modern browsers pick the second from the cascade.

Default: `false`. Composes with `pixi`: explicit `pixi: { roundPixels: false }` wins over the preset, so games can opt parts back out. The preset only sets the _default_, so a per-texture `texture(path, { scaleMode })` still decides for that image.

```ts
import { RendererPlugin } from "@yagejs/renderer";

declare const host: HTMLElement; // the page element that holds the game

new RendererPlugin({
  width: 320,
  height: 240,
  container: host,
  pixelArtPreset: true,
});
```

Registers `RendererKey`, `SceneRenderTreeProviderKey`, and the cross-package `RendererAdapterKey` (from `@yagejs/core`, consumed by `@yagejs/input`) in `EngineContext`, plus a `beforeEnter` scene hook that materializes a per-scene `SceneRenderTree` (accessible via the scene-scoped `SceneRenderTreeKey`).

The adapter contract (`RendererAdapter` in `@yagejs/core`) carries `canvas`, `canvasToVirtual`, `hitTestUI`, `hitTestUIPath` (the same test, reporting the container chain it crossed), `dispatchPointerEvent` (deliver a pointer event at a virtual-space point, which `inspector.pointer` drives the user interface through), and the optional `visibleVirtualRect` — the on-screen region of virtual space CLAMPED to the declared virtual rect. Renderer-agnostic overlays (e.g. `@yagejs-addons/virtual-controls`) lay out against `visibleVirtualRect`, NOT against `canvasToVirtual`-mapped canvas corners: under letterbox the corners map into the masked bars, where drawn content is clipped but pointer input still registers.

## Responsive fit

The canvas is **responsive by default** — it tracks a host element and re-maps the virtual rectangle on every resize. Without an explicit `fit` config, the renderer defaults to `{ mode: "letterbox" }` against the configured `container`, falling back to `canvas.parentElement`. There is no `document.body` fallback: a `ResizeObserver` on `body` fires on any page layout change, not just viewport resizes. With no host to observe, the fit applies once against `config.width × config.height`. Pass `fit: { target: document.body }` to opt in explicitly. `setFit({ mode })` changes the mode and keeps the current target. Fixed-size canvases are achieved via fixed CSS dimensions on the container.

```ts
import { RendererPlugin } from "@yagejs/renderer";

declare const host: HTMLElement; // the page element that holds the game

new RendererPlugin({
  width: 800,
  height: 600,
  container: host,
  // fit: { mode: "letterbox" }  // this is the default
  // fit: { mode: "cover" },     // or override
  // fit: { mode: "stretch", target: otherElement },
});
```

| Mode        | Scale                       | Offset                                   | When to pick                                                                                                                                                                                             |
| ----------- | --------------------------- | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `letterbox` | uniform `min(cw/vw, ch/vh)` | centers; bars in `backgroundColor`       | default — preserves aspect, full virtual rect visible                                                                                                                                                    |
| `expand`    | same as `letterbox`         | same as `letterbox`                      | virtual always fully visible, but the game draws into the bars instead of leaving them blank (fog, parallax, decorative backdrop, HUD). Matches Godot `expand`, Unity `Expand`, Construct "Scale inner". |
| `cover`     | uniform `max(cw/vw, ch/vh)` | centers; overflow clipped by canvas edge | fills the host; accept CSS-cover-style clipping on one axis. Rare for gameplay — aspect affects what the player sees.                                                                                    |
| `stretch`   | non-uniform per axis        | none                                     | fills the host; virtual rect squashed. Use for menus or editor panels, not gameplay.                                                                                                                     |

`letterbox` and `expand` produce the same stage transform. They differ in convention: letterbox expects bars to be the flat `backgroundColor`; expand expects the game to fill them via `extendedVirtualRects`.

A `ResizeObserver` drives updates; it's disposed in `onDestroy`. In headless environments (no DOM target, no `document`) the plugin applies a one-shot transform against the initial `width × height` and installs no observer.

**Give the fit container a bounded height.** The fit host's size is fed back into the canvas every resize, so a container with no height of its own (only content-driven height) has no stable size and the observer can grow without bound. The renderer sets `display:block` on the canvas, which removes the ~4px inline-canvas baseline gap that otherwise causes this and makes the common case converge with zero CSS. You still want the container to have an explicit or bounded height (`height: 100%` under a sized ancestor, or `max-height`). If a true feedback loop is detected anyway (residual margin / sub-pixel growth), `FitController` freezes auto-resize and logs a one-time `console.warn` rather than hang the tab.

Runtime API on the plugin:

```ts yage-context="scene-enter"
import { RendererKey } from "@yagejs/renderer";

const renderer = this.use(RendererKey); // in a Scene or a Component

renderer.setFit({ mode: "expand" }); // swap modes / target
renderer.fit; // current { mode, target? }
renderer.canvasSize; // current CSS { width, height }
renderer.canvasToVirtual(120, 80); // canvas CSS px → virtual (Vec2)
renderer.virtualToCanvas(160, 120); // virtual → canvas CSS px (Vec2)
renderer.visibleVirtualRect; // on-screen sub-rect of virtual (clamped)
renderer.croppedVirtualRects; // parts of virtual that are off-screen
renderer.virtualCanvasRect; // where virtual sits on the canvas (CSS px)
renderer.visibleCanvasRect; // full canvas extent in virtual px
renderer.extendedVirtualRects; // parts of canvas OUTSIDE virtual (bars)
```

### `visibleVirtualRect`

Sub-rectangle of the declared virtual space that's actually on-screen, clamped to virtual bounds. Anchor HUD / UI that must stay inside the play area to this rect; keep gameplay queries on `virtualSize`. Critical under `cover` for competitive games where a wider viewport must not grant a gameplay advantage: the play area stays `virtualSize`, but HUDs align to the visible sub-rect.

| Mode                               | `visibleVirtualRect`                                                                                 |
| ---------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `letterbox` / `expand` / `stretch` | full virtual rect: `{ 0, 0, virtualWidth, virtualHeight }`                                           |
| `cover`                            | cropped sub-rect on the long axis, e.g. `{ 0, 30, 400, 240 }` for 400×300 virtual in a 1000×600 host |

### `croppedVirtualRects`

Rectangles of virtual space that are currently off-screen — the complement of `visibleVirtualRect` inside `virtualSize`. Empty under `letterbox` / `expand` / `stretch`. Under `cover`, returns 1–2 strips on the cropped axis (top + bottom on a wide host, left + right on a tall host). Gameplay still runs in these regions; they're just clipped by the canvas edge.

Use when an effect needs to reason about what's beyond the visible edge under `cover` — fog-of-war overlays that fade at the crop boundary, edge-activity indicators, auto-panning cameras that keep action in view.

### `virtualCanvasRect`

Where the declared virtual rectangle sits on the canvas, in **CSS pixels**. Useful for positioning DOM overlays over the play area, cropping screenshots to gameplay, or mapping CSS-coord hit regions. Derived from the stage transform: `{ x: offsetX, y: offsetY, width: vW*scaleX, height: vH*scaleY }`. Under `cover` this rect extends past the canvas (negative coords, dimensions larger than `canvasSize`).

### `visibleCanvasRect`

Full canvas extent expressed in **virtual-space pixels**. Unlike `visibleVirtualRect`, not clamped to the declared virtual rect — under `letterbox` / `expand` on an off-aspect host this extends past `virtualSize` on the bar axis (negative `x` / `y` or `width` / `height` greater than the virtual dimension). Under `cover` it equals `visibleVirtualRect`; under `stretch` it equals the virtual rect.

Anchor HUD to this rect (not `visibleVirtualRect`) when you want cards to live in the bars under `expand`. Iterate over it for backdrops that should fill the whole visible canvas.

### `extendedVirtualRects`

Rectangles of the visible canvas that sit **outside** the declared virtual rect — the letterbox / expand "bars" expressed in virtual-space pixels. Complement of `virtualSize` inside `visibleCanvasRect`.

| Mode                   | `extendedVirtualRects`                                                                         |
| ---------------------- | ---------------------------------------------------------------------------------------------- |
| `letterbox` / `expand` | 0–2 bar strips when aspect mismatches (top + bottom on tall hosts, left + right on wide hosts) |
| `cover`                | `[]` (virtual covers the entire canvas)                                                        |
| `stretch`              | `[]` (virtual exactly fills the canvas)                                                        |

Under `expand` these are the play-adjacent strips the game is expected to draw into. The `responsive-ui` example fills each with a solid dark rect plus a short gradient along the inner edge (touching the play area) so the bars read as "not the play area, but still part of the rendered world."

Under `letterbox` the same rects only report where the `backgroundColor` bars land. That mode clips every scene layer to the virtual rect, so content placed at these coordinates on a scene layer is invisible. To draw in the bars, parent a container directly on `renderer.application.stage` and position it in **canvas pixels**:

```ts yage-context="scene-enter"
import { Container, Graphics } from "pixi.js";
import { RendererKey } from "@yagejs/renderer";

const renderer = this.use(RendererKey);
const bars = new Container();
renderer.application.stage.addChild(bars); // canvas px, above the fit transform
const { width } = renderer.canvasSize;
const play = renderer.virtualCanvasRect; // play area, in canvas px
const branding = new Graphics();
branding.rect(0, 0, width, play.y).fill({ color: 0x0f172a }); // top bar
bars.addChild(branding);
```

Note: "screen" in the engine (UI `LayerSpace: "screen"`, `Camera.screenToWorld`) means _virtual viewport space_. The `canvasToVirtual` method is named after its inputs (DOM CSS pixels on the canvas) to avoid that collision.

Pair with `@yagejs/input` — `InputPlugin` auto-resolves the renderer via `RendererAdapterKey` (core), so pointer events target this canvas and coordinates route through `canvasToVirtual` without extra setup. `InputManager.getPointerPosition()` stays correct under fit with no config.

## Fullscreen & Orientation

`RendererPlugin` wraps the browser fullscreen API (with `webkitRequestFullscreen` fallback for iOS Safari) and emits viewport-lifecycle events on the engine bus. The fullscreen target is the configured `container` when present, falling back to the canvas.

| Member                                 | Purpose                                                                                                                                |
| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `requestFullscreen(): Promise<void>`   | Enter fullscreen on the host. Must be called from a user gesture. Rejects if unsupported.                                              |
| `exitFullscreen(): Promise<void>`      | Exit fullscreen. No-op if not currently fullscreen.                                                                                    |
| `isFullscreen: boolean`                | Live read of `document.fullscreenElement === host`.                                                                                    |
| `orientation: OrientationType \| null` | Current device orientation (`portrait-primary`, `landscape-primary`, etc.), or `null` when neither modern nor legacy API is available. |

Emits on the engine `EventBus`:

| Event                | Payload                     | When                                                                                |
| -------------------- | --------------------------- | ----------------------------------------------------------------------------------- |
| `screen:fullscreen`  | `{ active: boolean }`       | `fullscreenchange` / `webkitfullscreenchange` (entering, exiting, Esc, browser UI). |
| `screen:orientation` | `{ type: OrientationType }` | `screen.orientation.change` if available, else `window.orientationchange` fallback. |

```ts yage-context="engine"
import { EventBusKey } from "@yagejs/core";
import { RendererPlugin } from "@yagejs/renderer";

declare const host: HTMLElement; // the page element that holds the game
declare const button: HTMLButtonElement; // a "Fullscreen" button
declare function layoutHud(orientation: OrientationType): void; // game code

const renderer = new RendererPlugin({
  width: 800,
  height: 600,
  container: host,
});
engine.use(renderer); // use() returns the engine, not the plugin
const bus = engine.context.resolve(EventBusKey);
bus.on("screen:orientation", ({ type }) => layoutHud(type));
button.addEventListener("click", () => {
  renderer.requestFullscreen().catch(console.warn); // rejects if unsupported
});
```

Listeners are registered in `install()` (gated by `typeof document/window !== "undefined"`) and torn down in `onDestroy()`. iOS Safari requires `requestFullscreen` to run inside a user-gesture handler.

## Mobile readiness

Page setup so a game gets the whole visible screen on phones. Both `create-yage` templates ship the `index.html` part; `recommended` also ships the fullscreen button (`src/fullscreen.ts`) and the installable-app tags.

```html
<meta
  name="viewport"
  content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<style>
  #game {
    box-sizing: border-box;
    background: #0f172a; /* the renderer's backgroundColor */
    width: 100vw;
    height: 100vh; /* fallback for browsers without dvh */
    height: 100dvh;
    padding: env(safe-area-inset-top) env(safe-area-inset-right)
      env(safe-area-inset-bottom) env(safe-area-inset-left);
  }
</style>
```

- `viewport-fit=cover`: page covers the notch and rounded-corner areas. Without it iOS insets the page and every `env(safe-area-inset-*)` is `0`.
- `100dvh`, not `100vh`: on phones `100vh` is the height with the toolbars hidden, so the bottom of the game sits behind them. Never rely on scrolling to hide the toolbars.
- Safe-area padding on the `container`: `RendererPlugin` fits the game inside the container's content box, so the padding keeps it clear of the notch, corners and home indicator with no renderer config. Give the container its own background (`backgroundColor`): in fullscreen only the container is drawn, so a `body` background alone leaves black strips in the padding.
- Fullscreen button: place it inside the `container` (the element that goes fullscreen) so it stays visible. Show it only when fullscreen is available and the page is not already an installed app running fullscreen:

  ```ts
  declare const button: HTMLButtonElement; // the fullscreen button
  const doc = document as Document & { webkitFullscreenEnabled?: boolean }; // not in lib.dom
  button.hidden =
    matchMedia("(display-mode: fullscreen)").matches ||
    !(doc.fullscreenEnabled === true || doc.webkitFullscreenEnabled === true);
  ```

  The prefixed flag covers iPad Safari < 16.4. Neither flag is `true` on iPhone (no element fullscreen) or in an iframe without `allow="fullscreen"`. On iPad, Safari overlays its own exit button and a swipe down exits fullscreen. Call `button.blur()` in the click handler, or the focused button takes game keys such as Enter.

- Rotate overlay: Safari has no `screen.orientation.lock()`; Chrome on Android only locks while fullscreen or installed. Show a "rotate the device" overlay from `screen:orientation`, and read `renderer.orientation` once at startup because the event fires only on change.
- Add to Home Screen is the only way to remove Safari's toolbars on iPhone. No install prompt on iOS (player uses Share → Add to Home Screen). iOS 26+ opens every Home Screen site without toolbars; earlier iOS needs a web app manifest with `display: "standalone"` (iOS does not support `"fullscreen"`; add `display_override: ["fullscreen"]` for Android, which iOS ignores). Tags: `<link rel="apple-touch-icon" href="/apple-touch-icon.png">` (180×180) and `<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">` (installed game draws under the status bar; the safe-area padding clears it). The `recommended` template's `vite-plugin-pwa` setup ships this manifest and both tags; see `quick-start.md`.

## Components

### Pick a component

| Need                                                                                 | Use                                                                          |
| ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
| Render an asset / texture                                                            | `SpriteComponent`                                                            |
| Frame-based animation                                                                | `AnimatedSpriteComponent` (+ `AnimationController`)                          |
| Procedural shapes (debug, prototypes, gradient overlays, custom drawing)             | `GraphicsComponent`                                                          |
| Text with layout, padding, backdrop, "card" widget                                   | `UIText` + `UISurface` from `@yagejs/ui`                                     |
| Entity-tracked text that stays axis-aligned at any zoom (nameplates, damage numbers) | `ScreenFollow` + `UISurface({ positioning: "transform" })` from `@yagejs/ui` |
| Free-positioned single string (debug HUD, diegetic world-space label)                | `TextComponent`                                                              |

Default to `@yagejs/ui` for any text that lives inside a widget, has padding, or stacks with other rows. `TextComponent` is the narrow case where the text is its own world-space primitive with no layout.

For procedural shapes plus a label, use a parent entity with `GraphicsComponent` + a child entity with `TextComponent`. Pixi v8 has no `g.text(...)` method: text is always a separate display object.

### Shared options vocabulary

All five visual components below (Sprite, AnimatedSprite, Graphics, Text, SplitText) accept the same `visible` / `tint` / `alpha` / `blendMode` / `interactive` options, with runtime accessors for the first four. Pixi's `Container` carries all five natively, so the behavior is identical across every component:

```ts
import type {
  BlendMode,
  ColorValue,
  VisualComponentOptions as BaseVisualComponentOptions,
} from "@yagejs/renderer";

interface VisualComponentOptions extends BaseVisualComponentOptions {
  visible?: boolean; // initial visibility, default true
  tint?: ColorValue; // number (0xff0000) or CSS color string ("red", "#ff0000")
  alpha?: number; // opacity, default 1
  blendMode?: BlendMode; // how the pixels combine with what is beneath, default "inherit"
  interactive?: {
    eventMode?: "static" | "dynamic"; // default "static" when the object is set
    consumeOnInteraction?: boolean; // claim the press for @yagejs/input's action map
  };
  renderAboveEffects?: boolean; // draw outside layer/scene effects, default false
}
```

`comp.tint` and `comp.blendMode` read/write the live object. `comp.visible` and
`comp.alpha` read/write the game's base values; active modifiers affect only
the computed render values. `interactive` is set once at construction.
`comp.renderAboveEffects` toggles at runtime — see [Effects](#effects) for what
it escapes.

Every visual component also exposes `comp.modifiers: VisualModifierHost`:

```ts yage-context="entity"
import { SpriteComponent } from "@yagejs/renderer";

const comp = entity.get(SpriteComponent); // any of the five visual components

const motion = comp.modifiers.addTransform({
  position: { x: 0, y: -4 }, // Vec2Like offset
  rotation: 0.1, // radians
  scale: 1.2, // number or Vec2Like
});
motion.setPosition({ x: 0, y: -8 });
motion.setRotation(0.2);
motion.setScale({ x: 1, y: 1.1 });
motion.remove();

const opacity = comp.modifiers.addOpacity(0.5); // multiplicative
const visibility = comp.modifiers.addVisibility(false); // logical AND
```

Transform position/rotation modifiers add; scale and opacity multiply.
`DisplaySystem` combines them with the current world `Transform` every render
without changing `Transform`. Depth sorting sees the base position. Handles
remove only their own contribution, are idempotent, and become inactive when
the component is destroyed. Modifiers are runtime-only.

**`blendMode`.** `"normal"`, `"add"`, `"multiply"`, `"screen"`, `"erase"`, `"min"`, `"max"`, `"none"` and the `-npm` variants are GPU-native and need nothing extra. The photoshop-style rest (`"darken"`, `"lighten"`, `"overlay"`, `"color-dodge"`, `"soft-light"`, ...) are filter-backed and need one side-effect import in the game's entry file — without it Pixi logs a warning and draws normally:

```ts
import "pixi.js/advanced-blend-modes";
```

Pixi constructs every display object at `"inherit"`, not `"normal"` — inherited blending renders as normal until an ancestor sets a mode, and the two differ under a non-normal parent. `"erase"` composites against whatever framebuffer the object lands in, so it only cuts a hole out of the darkness you intend when both are drawn into their own offscreen buffer — see [Offscreen render targets](#offscreen-render-targets).

**`anchor` and `pivot`.** A visual is drawn with one point of its art on the entity's world position, and the entity's rotation and scale turn and size the art about that same point. `anchor` (`{x, y}`, a fraction of the texture) chooses which point that is: `{ x: 0.5, y: 0.5 }` turns the art about its own middle, `{ x: 0.5, y: 1 }` about its bottom edge. Sprite, AnimatedSprite, Text, and SplitText take it. Graphics has none (a raw Pixi `Container` has no anchor) and takes `pivot` (`{x, y}`) instead — the same idea in the drawing's own pixels, since a drawing has no texture size to take a fraction of. SplitText also has per-segment `charAnchor` / `wordAnchor` / `lineAnchor` values (see below). Neither option turns art about a point outside the entity: for that, parent the visual's entity under an entity placed at the turning point. A `@yagejs/ui` element places differently: layout puts its top-left corner, and its `transformOrigin` prop moves only the point its `scale` and `rotation` turn about (see `ui.md`, "Scale, rotation and draw order").

### SpriteComponent

```ts yage-context="entity"
import { SpriteComponent } from "@yagejs/renderer";

entity.add(
  new SpriteComponent({
    texture: "hero.png", // TextureRef: asset key or handle
    layer: "world", // render layer name
    anchor: { x: 0.5, y: 0.5 },
    tint: 0xff0000,
    interactive: { consumeOnInteraction: true },
  }),
);
```

`texture` takes a `TextureRef` (asset key or handle), not a raw `Texture` object. A key that is neither preloaded nor registered throws, naming the key. Runtime-created textures: register them under a key first — see "Runtime textures" under Asset Factories.

**Escape hatch:** `.sprite` is the underlying pixi `Sprite` instance — full pixi API surface available, including `sprite.tint`. See [pixi Sprite docs](https://pixijs.com/8.x/guides/components/scene-objects/sprite).

> `sprite.tint` multiplies the source RGB by the tint colour. That's cheap on the GPU and right for "darken / desaturate / multiply with a colour" effects, but it turns saturated source colours into mud (a blue mushroom × yellow tint reads as olive). For replace-style recolour — where black stays black, white reaches the target colour, and midtones blend proportionally — use the `colorize` effect from `@yagejs/effects` instead.

### GraphicsComponent

Procedural drawing via PixiJS Graphics API:

```ts yage-context="entity"
import { GraphicsComponent } from "@yagejs/renderer";

entity.add(
  new GraphicsComponent({ layer: "world", tint: 0x88ccff }).draw((g) => {
    g.rect(0, 0, 50, 50).fill(0xff0000);
  }),
);
```

Graphics and their draw callbacks are runtime resources. Save the durable game facts that determine the drawing, then call `draw()` during normal component setup.

`pivot` (`{x, y}`, in the drawing's own pixels, default `{ x: 0, y: 0 }`) names the point of the drawing that sits on the entity position and that rotation and scale act about. It is the Graphics counterpart of `anchor`. Both numbers must be finite; a `NaN` throws naming `pivot.x` or `pivot.y`.

```ts yage-context="entity"
import { GraphicsComponent } from "@yagejs/renderer";

// A barrel drawn from its base upwards, rolling about its middle.
entity.add(
  new GraphicsComponent({ layer: "world", pivot: { x: 0, y: -24 } }).draw(
    (g) => {
      g.circle(0, -24, 24).fill(0x8b5a2b);
    },
  ),
);
```

Drawing the shape around `(0, 0)` in the callback reaches the same result. `pivot` is for the cases where the drawing's coordinates are fixed by something else: a shared draw callback used by entities that turn about different points, or a layout whose numbers come from data.

The context also carries a transform stack, so a repeated part draws in its own
coordinates without the callback computing offsets.
`translateTransform(x, y?)`, `scaleTransform(x, y?)` and
`rotateTransform(angle)` multiply into the current transform.
`setTransform(matrix)` — or six numbers — replaces it, and `resetTransform()`
returns it to identity. `save()` pushes the current transform and `restore()`
pops it. Every one of them returns the context, so calls chain.

```ts yage-context="entity"
import { GraphicsComponent } from "@yagejs/renderer";

entity.add(
  new GraphicsComponent({ layer: "world" }).draw((g) => {
    for (let i = 0; i < 6; i++) {
      g.save();
      g.translateTransform(i * 24, 0).rotateTransform(i * 0.2);
      g.rect(-6, -6, 12, 12).fill(0xffcc00); // drawn around its own centre
      g.restore();
    }
  }),
);
```

**Escape hatch:** `.graphics` (and the `g` passed to `.draw(fn)`) is a raw pixi `Graphics` with the v8 fluent API: `rect` / `circle` / `roundRect` / `poly` / `moveTo` / `lineTo` / `arc` / `fill` / `stroke`. `arc` continues the current path like Canvas 2D: call `moveTo(x, y)` at the arc's start point first for a standalone arc, otherwise a line connects it from the previous point. See [pixi Graphics docs](https://pixijs.com/8.x/guides/components/scene-objects/graphics).

Gradient fills: use `linearGradient` / `radialGradient` (see below) instead of reaching into `pixi.js` for `FillGradient`.

Texture fills: `.fill({ texture, textureSpace: "global" })` tiles a texture across the shape — see "Texture fills" below.

### TextComponent

Renders text on a layer, Transform-synced like sprites. For free-positioned strings only — for laid-out text widgets, use `UISurface` + `UIText` from `@yagejs/ui` (see "Pick a component" above).

```ts yage-context="entity"
import { TextComponent } from "@yagejs/renderer";

entity.add(
  new TextComponent({
    text: "DEBUG",
    layer: "debug",
    anchor: { x: 0.5, y: 0 },
    style: {
      fontFamily: "ui-monospace, monospace",
      fontSize: 14,
      fill: 0xf8fafc,
      fontWeight: "bold",
    },
    interactive: { consumeOnInteraction: true }, // marks a raw-Text pointer-consume surface
  }),
);
```

**Thin wrapper:** `style` forwards as-is to pixi `TextStyleOptions` (CSS-style font properties — `fontFamily`, `fontSize`, `fontWeight`, `fontStyle`, `fill`, `letterSpacing`, `lineHeight`, etc.). `.text` is the underlying pixi `Text` (or `BitmapText` when `bitmap` is set). See [pixi Text docs](https://pixijs.com/8.x/guides/components/scene-objects/text/) and [pixi TextStyle reference](https://pixijs.com/8.x/guides/components/scene-objects/text/style).

**Pixel-art text — `bitmap`.** Canvas-rasterised `Text` is bilinear-sampled by the GPU, so it goes blurry at non-integer scale (camera zoom, pixel-art upscaling) on non-Retina displays. Set `bitmap` to draw pre-baked glyph quads instead:

```ts
import { TextComponent } from "@yagejs/renderer";

// `bitmap: true` bakes (or looks up) the atlas from `style.fontFamily`
// at `style.fontSize` — the font is a normal style property.
new TextComponent({
  text: "SCORE",
  bitmap: true,
  style: { fontFamily: "monospace", fontSize: 12 },
});

// An installed / loaded bitmap font: name it via fontFamily.
new TextComponent({
  text: "READY",
  bitmap: true,
  style: { fontFamily: "PressStart", fontSize: 16 },
});
```

`bitmap` (boolean) and `resolution` are construction options. Yoga/layout behaviour is unchanged.

**`setText` writes, `content` reads.** `setText(value)` replaces the displayed string; `content` reads it back. (`.text` is the pixi display object, not the string.) `content` is also what the Inspector reports for a `TextComponent`, so an e2e assertion on rendered copy reads `getComponentData(entity, "TextComponent").content`.

**`setStyle` replaces, `mergeStyle` patches.** `setStyle(style)` assigns a fresh style — properties you omit fall back to the defaults. `mergeStyle(style)` merges over the properties already set, so an imperative recolour (`mergeStyle({ fill })`) keeps the current font, size, weight, etc. The React reconciler uses `setStyle` (declarative: the full style is passed every render).

**`resolution` gotcha (Pixi v8).** `resolution` is a `Text` _constructor_ option, NOT a `TextStyle` property. Setting `TextStyle.defaultTextStyle.resolution` does nothing. Pass it explicitly to get crisp canvas text without a prototype patch — or use `bitmap` for pixel-perfect rendering:

```ts
import { TextComponent } from "@yagejs/renderer";

new TextComponent({ text: "HUD", resolution: window.devicePixelRatio });
```

`resolution` is ignored when `bitmap` is set — bitmap resolution is fixed when the font is baked (see `installBitmapFont({ resolution })`).

**Engine default text style.** `new RendererPlugin({ defaultTextStyle: { fontFamily, fill } })` sets an app-wide base under every `TextComponent` / `UIText` `style` (per-text values win) — no need to import pixi to touch `TextStyle.defaultTextStyle`. `@yagejs/ui`'s `UIPlugin({ defaultTextStyle })` layers a UI-only override on top (precedence: per-text style > UIPlugin default > RendererPlugin default > pixi default). The default also re-applies on `setStyle`, so a recolour keeps it.

**`bitmap` is a sibling of `style`, not a style key.** Merging it into `style` (`style: { …, bitmap: true }`) is ignored and emits a dev warning — keep it top-level: `{ style: { … }, bitmap: true }`.

### SplitTextComponent

Per-glyph / animated text — typewriter reveals, per-letter colour/wave, staggered line entrances. Wraps Pixi v8's **experimental** `SplitText` / `SplitBitmapText`; exposes the text as arrays of individually transformable display objects. Transform-synced and layer-attached like `TextComponent`.

```ts yage-context="entity"
import { SplitTextComponent } from "@yagejs/renderer";
import { Tween, ProcessComponent } from "@yagejs/core";

const title = entity.add(
  new SplitTextComponent({
    text: "GAME OVER",
    style: { fontSize: 48, fill: 0xffffff },
    bitmap: true, // optional — SplitBitmapText (font via style.fontFamily)
    anchor: { x: 0.5, y: 0.5 }, // turning point of the whole text block
    charAnchor: 0.5, // segment turning points (0–1): char / word / lineAnchor
    // autoSplit: false,              // batch text/style edits, then resplit()
  }),
);

title.chars; // (Text | BitmapText)[] — one per glyph
title.words; // Container[] — word groups
title.lines; // Container[] — line groups

// Typewriter: stagger each glyph's fade-in (0.05s apart) via a ProcessComponent.
title.chars.forEach((c) => (c.alpha = 0));
const pc = entity.add(new ProcessComponent());
Tween.stagger(
  title.chars,
  (c) => Tween.custom((v) => (c.alpha = v), 0, 1, 0.3),
  0.05,
).forEach((p) => pc.run(p));
```

`anchor` positions the whole block around its entity Transform. It uses the current split text bounds and is recomputed after text, style, or manual split updates. `charAnchor`, `wordAnchor`, and `lineAnchor` only affect their individual segments.

API: `chars` / `words` / `lines` (getters), `setText(v)`, `content` (reads the current string back), `setStyle(s)`, `charAnchor` / `wordAnchor` / `lineAnchor` (get/set), `resplit()` (manual split when `autoSplit: false`), `visible` / `tint` / `alpha`, `fx` / `setMask` / `clearMask` (same effects/mask surface as the other four components), `splitText` (underlying Pixi object), `isBitmap`. Caveats: `SplitText` is experimental, re-lays-out on every `text`/`style` change (prefer `TextComponent` for static/simple text), and char spacing can differ slightly from `Text` (kerning lost when glyphs split).

### AnimatedSpriteComponent

`source` is required; there is no raw-`Texture[]` construction path. A `FrameSource` is either a sheet (`SheetFrameSource`: `{ sheet, frameWidth, frameHeight?, count?, columns?, startX?, startY?, gapX?, gapY? }` — top row by default; `count` wraps rows every `columns` frames for multi-row grid sheets) or an atlas animation (`{ atlas, animation }`). `sliceGrid(texture, options)` is the underlying slicer for use when you already have a `Texture` object.

`sheet` is a `TextureRef`: an asset key or the handle `texture(path)` returns — the same pair `SpriteComponent`'s `texture` accepts, so a preload declaration flows straight into a frame source. `atlas` stays a key, because an atlas resolves as a `Spritesheet` and not as a texture.

Every slicing entry (`sliceGrid`, `sliceSheet`, `sliceTextureFrames`, and a `SheetFrameSource`) validates its options: each field must be a finite number at or above its minimum, and the resulting grid must fit inside the texture. A failure throws naming the function and the offending field. Slicing does not change how the texture is sampled — turn on `pixelArtPreset` for the whole project, or `texture(path, { scaleMode: "nearest" })` for one sheet.

```ts yage-context="entity"
import { AnimatedSpriteComponent } from "@yagejs/renderer";

const player = entity.add(
  new AnimatedSpriteComponent({
    source: { sheet: "player_idle.png", frameWidth: 48 }, // single-row strip
    layer: "world",
    anchor: { x: 0.5, y: 1 },
    tint: 0xffffff,
    speed: 0.15, // starting playback rate
  }),
);

// multi-row grid sheet: 48 frames wrapped across width-derived columns
new AnimatedSpriteComponent({
  source: {
    sheet: "boxer_idle.png",
    frameWidth: 126,
    frameHeight: 132,
    count: 48,
  },
});

player.play({ speed: 0.15, loop: true });
player.speed = 0.3; // retime the running clip, no restart
player.gotoFrame(3); // stop and hold a pose
player.play({ speed: 0.2, loop: false, fromStart: true }); // one-shot from frame 0
```

Use `gotoFrame(index)` to stop playback and hold one frame; read the selected
index through `frame`. A bare `play()` resumes from the current frame. Pass
`fromStart: true` to restart at frame 0, including replaying a completed
non-looping animation.

`play(options?)` owns its completion callback: a play with no `onComplete` clears the one the previous play installed, and an `AnimationController` animation switch clears it too. `speed` and `loop` are sticky — the next play keeps them.

The `speed` construction option sets the rate the first `play()` runs at, so a clip that starts as soon as it mounts needs no `play({ speed })`. A non-finite value throws naming the component and the option.

`speed` (get/set) is the live playback rate, and the same number `play({ speed })` writes: frames advanced per tick at 60 fps, default `1`. Writing it retimes a running clip without restarting it. `0` holds the current frame while `isPlaying` stays `true`, and a negative value plays backwards. A non-finite value throws from the property and from `play({ speed })` alike. With an `AnimationController` on the entity, `speed` reads the composed rate: the animation definition's `speed` times `controller.speed` times the `playOneShot({ speed })` factor. The controller writes the rate again at its next animation switch and whenever `controller.speed` is written, so set `AnimationController.speed` to retime every animation and the component's `speed` to retime only the clip on screen. The component's `speed` does not retime a running one-shot's lock: the lock keeps the duration computed when the one-shot started, so the clip and the lock can end at different times.

`onFrameChange(listener)` subscribes to frame changes and returns an unsubscribe function, so `this.addCleanup(sprite.onFrameChange(fn))` drops the listener with the component. Any number of listeners can subscribe; each receives the new frame index. Pixi delivers a frame change on play, on every advance, and on the frame reset an animation switch performs, so a listener sees controller switches too. Assigning `animatedSprite.onFrameChange` directly replaces the engine's dispatcher and silences every subscriber.

```ts
import { Component } from "@yagejs/core";
import { AudioManagerKey } from "@yagejs/audio";
import { AnimatedSpriteComponent } from "@yagejs/renderer";

class Footsteps extends Component {
  onAdd(): void {
    const sprite = this.entity.get(AnimatedSpriteComponent);
    this.addCleanup(
      sprite.onFrameChange((frame) => {
        if (frame === 4) this.use(AudioManagerKey).play("footstep");
      }),
    );
  }
}
```

Playback runs in engine-scaled component time. `scene.timeScale` and
`entity.timeScale` compose with Pixi's `animationSpeed`; a paused scene,
`scene.timeScale = 0`, or a disabled component freezes the animation. Host an
animation in a separate active overlay scene when it must keep playing while
gameplay is frozen.

`resolveFrames(source: FrameSource): Texture[]` turns either `FrameSource` shape
into the frame textures the component would use, for code that wants the
textures rather than an animation. It is synchronous and reads already-loaded
assets, so the sheet or atlas has to be in the scene's preload. An unloaded
sheet, a missing atlas, an unknown animation name and an empty animation each
throw naming the key. Index the result for a single frame — a `UIImage` takes
one `TextureInput`, not an array:

```ts
import { resolveFrames } from "@yagejs/renderer";
import { UIImage } from "@yagejs/ui";

const icon = new UIImage({
  texture: resolveFrames({ atlas: "ui.json", animation: "coin" })[0]!,
});
```

**Escape hatch:** `.animatedSprite` is the underlying pixi `AnimatedSprite`.

### AnimationController

Named animation state machine with one-shot locking:

```ts yage-context="entity"
import { AnimationController } from "@yagejs/renderer";

entity.add(
  new AnimationController<"idle" | "walk" | "attack">({
    idle: { source: { sheet: "player_idle.png", frameWidth: 48 }, speed: 0.15 },
    walk: { source: { sheet: "player_walk.png", frameWidth: 48 }, speed: 0.2 },
    attack: {
      source: { sheet: "player_attack.png", frameWidth: 48 },
      speed: 0.25,
      loop: false,
    },
  }),
);

// In component:
const anim = entity.get(AnimationController);
anim.play("walk");
anim.playOneShot("attack"); // locks until complete; the sprite holds the last frame
```

### Typing the controller

`AnimationController<T extends string = string>` is generic on the animation-name union — `play("walk")` autocompletes, and a typo like `play("wal")` is a compile error. But the runtime class isn't generic: there's no `AnimationController<HeroAnim>` expression to pass to `entity.get()` or `Component.sibling()`, and a default `AnimationController<string>` isn't sound-assignable to `AnimationController<HeroAnim>` (the `current: T | ""` getter is covariant on `T`, so a string-returning instance can't substitute for one promising the narrow union). Annotate the field with an `as` cast — the cast is required because the type parameter is type-only, and the field annotation makes every downstream call site narrow automatically:

```ts
import { Component } from "@yagejs/core";
import { AnimationController } from "@yagejs/renderer";

type HeroAnim = "idle" | "walk" | "attack";

class HeroController extends Component {
  private readonly _anim = this.sibling(
    AnimationController,
  ) as AnimationController<HeroAnim>;

  update(): void {
    this._anim.play("walk"); // typed — typo here is a compile error
  }
}
```

`playOneShot(name, options?)` — `options` is `{ startFrame?, speed?, duration?, onComplete?, onCancel? }`.

- `startFrame` (integer, 0 to the last frame) starts the clip partway in.
- `speed` (finite, > 0) multiplies playback for this one-shot only; an ordinary `play()` afterwards runs at the definition's own speed.
- `duration` (engine-scaled seconds) overrides the auto-computed lock duration. The fallback uses the frames that will actually play: `((frames - startFrame) * (1 / 60)) / (AnimationDef.speed * controller.speed * options.speed)`. The effective speed must be positive and the calculated duration must be finite.
- Exactly one of `onComplete` and `onCancel` runs per one-shot: `onComplete` when the lock timer plays out, `onCancel` when another one-shot, `forcePlay`, `unlock` or destroying the component ends it first. Neither runs when `playOneShot` re-plays the animation that is already locked (a no-op), and `onCancel` runs after the new state is installed, so calling `playOneShot` from inside it takes effect. An `onCancel` from destruction runs while the entity is already marked destroyed.

The lock timer and sprite playback receive the same scene and entity time scaling. Pass an explicit `duration` when synchronising lock release across multiple controllers or when automatic timing cannot produce a valid duration (see `LayeredAnimationController` below).

`play`, `playOneShot`, `forcePlay` and `calcDuration` throw naming the method, the name and the defined set when the animation is not defined; nothing changes on the controller. `has(name)` reports whether a name is defined (and narrows the type), for names driven from data.

Writing `controller.speed` updates the animation currently playing. An automatically timed one-shot preserves its playback progress and recomputes the remaining lock time. An explicit one-shot `duration` remains unchanged.

### LayeredAnimationController

Fans `play()` / `playOneShot()` across N sibling `AnimationController` instances with a single shared lock timer. Use this when a character is composed of multiple sprite layers (head + body + outfit) that must animate in lockstep:

```ts yage-context="scene"
import { Entity, Transform } from "@yagejs/core";
import {
  AnimatedSpriteComponent,
  AnimationController,
  LayeredAnimationController,
} from "@yagejs/renderer";

class HeroLayer extends Entity {
  setup({ sheet }: { sheet: string }) {
    this.add(new Transform());
    this.add(
      new AnimatedSpriteComponent({ source: { sheet, frameWidth: 48 } }),
    );
    this.add(
      new AnimationController({
        idle: { source: { sheet, frameWidth: 48 }, speed: 0.15 },
        attack: { source: { sheet, frameWidth: 48 }, speed: 0.25, loop: false },
      }),
    );
  }
}

class Hero extends Entity {
  setup() {
    this.add(new Transform());
    const body = this.spawnChild("body", HeroLayer, { sheet: "body.png" });
    const head = this.spawnChild("head", HeroLayer, { sheet: "head.png" });
    this.add(
      new LayeredAnimationController<"idle" | "attack">({
        controllers: [
          body.get(AnimationController) as AnimationController<
            "idle" | "attack"
          >,
          head.get(AnimationController) as AnimationController<
            "idle" | "attack"
          >,
        ],
      }),
    );
  }
}

const hero = scene.spawn(Hero);
const layered = hero.get(LayeredAnimationController);
layered.play("idle");
layered.speed = 1.5; // applies to every child
layered.playOneShot("attack", { onComplete: () => layered.play("idle") });
```

- `play(name)` forwards to every child.
- `speed` reads the first controller's runtime multiplier and writes a new value to every child. While the wrapper is attached, writing `speed` on any participating controller also updates the group. During an automatically timed one-shot, the lead timer retimes at its current progress.
- `playOneShot(name, opts)` uses the first controller's lock timer. Every other child receives `Number.POSITIVE_INFINITY` and stays locked until the lead timer releases the group. `startFrame` and `speed` are forwarded to every layer. With automatic timing every layer must accept that timing and produce a positive finite duration, checked before any layer switches — layers can define the same name with different frame counts, so a `startFrame` legal for one can be out of range for another. `opts.duration` keeps the lead timer fixed and replaces that calculation.
- The wrapper owns the interruption signal: exactly one of `onComplete` and `onCancel` runs per one-shot, and layers never receive an `onCancel` of their own.
- Every layer must define every name played through the wrapper. `play`, `playOneShot` and `forcePlay` throw naming the layer index before any layer switches.
- Rebuild the wrapper through the same `setup()` path whenever the scene is constructed.

### Layered characters: one-shot lock drift (the underlying problem)

`LayeredAnimationController` is the recommended fix. If you'd rather not introduce a wrapper component — for prototypes, or when each layer already has a custom controller — the same fix can be written as a short helper function. The underlying issue: `AnimationController.playOneShot` computes its lock duration from `frames.length / speed` (in whole-frame increments). When layers have different frame counts or speeds (a 12-frame outfit at `speed: 0.2` and a 10-frame body at `speed: 0.18` round differently), the locks expire on different frames and one sprite snaps back to idle while the others are still mid-swing — a single layer flickering at the end of every attack animation.

Use the first controller's automatic timer and keep the other controllers
locked until it completes:

```ts yage-context="entity"
import { AnimationController } from "@yagejs/renderer";

function playOneShotLayered(
  controllers: AnimationController<string>[],
  name: string,
  onComplete?: () => void,
): void {
  const leader = controllers[0]!;
  leader.playOneShot(name, {
    onComplete: () => {
      for (let i = 1; i < controllers.length; i++) controllers[i]!.unlock();
      onComplete?.();
    },
  });
  for (let i = 1; i < controllers.length; i++) {
    controllers[i]!.playOneShot(name, { duration: Number.POSITIVE_INFINITY });
  }
}

// `entity` is the character; each layer is a child with its own controller.
const bodyAnim = entity.getChild("body").get(AnimationController);
const headAnim = entity.getChild("head").get(AnimationController);
const outfitAnim = entity.getChild("outfit").get(AnimationController);
playOneShotLayered([bodyAnim, headAnim, outfitAnim], "attack");
```

`calcDuration(name, options?)` is public on `AnimationController`, takes the same `startFrame` / `speed` as a one-shot, and rejects speeds that cannot produce a valid automatic duration.

## Gradient fills

`linearGradient` and `radialGradient` return a `GradientFill` (pixi `FillGradient` internally) usable anywhere a graphics fill style is accepted. Stops use yage-style numeric color + alpha pairs — no CSS color strings needed.

```ts yage-context="entity"
import {
  linearGradient,
  radialGradient,
  GraphicsComponent,
} from "@yagejs/renderer";

const fade = linearGradient({
  axis: "vertical", // or "horizontal", or explicit start/end points
  stops: [
    { offset: 0, color: 0x000000, alpha: 0.8 },
    { offset: 1, color: 0x000000, alpha: 0 },
  ],
  // space: "local" (default) scales stops across the filled shape.
  // "global" treats them as world/screen pixels.
});

entity.add(
  new GraphicsComponent({ layer: "fog" }).draw((g) => {
    g.rect(0, 0, 200, 40).fill(fade);
  }),
);

const spotlight = radialGradient({
  center: { x: 0.5, y: 0.5 },
  innerRadius: 0,
  outerRadius: 0.5,
  stops: [
    { offset: 0, color: 0xffffff, alpha: 1 },
    { offset: 1, color: 0xffffff, alpha: 0 },
  ],
});
```

`GradientFill` owns a GPU texture; call `.destroy()` in `onDestroy()` when the owning component tears down. Components can safely build gradients in field initializers — just destroy them in `onDestroy()`.

**Re-export:** `GradientFill` IS pixi `FillGradient`. The factories convert yage's numeric stops to pixi color stops and forward the rest as-is. See [pixi FillGradient docs](https://pixijs.download/release/docs/scene.FillGradient.html).

## Texture fills

`.fill()` takes a texture as well as a colour or a gradient, and the texture
tiles across the shape — a 64x32 strip fills a 1200-pixel band with a repeating
pattern, not one stretched copy.

```ts
import { Component } from "@yagejs/core";
import { GraphicsComponent, RendererKey } from "@yagejs/renderer";
import type { TextureResource } from "@yagejs/renderer";

// createTexture returns a caller-owned GPU texture, so a component bakes it
// and destroys it again.
class SkyBand extends Component {
  private band!: TextureResource;

  onAdd(): void {
    this.band = this.use(RendererKey).createTexture(
      (g) => {
        g.rect(0, 0, 64, 16).fill(0x1b3a5c);
        g.rect(0, 16, 64, 16).fill(0x24507d);
      },
      { width: 64, height: 32 },
    );

    this.entity.add(
      new GraphicsComponent({ layer: "sky" }).draw((g) => {
        g.rect(0, 0, 1200, 400).fill({
          texture: this.band,
          textureSpace: "global",
        });
      }),
    );
  }

  onDestroy(): void {
    this.band.destroy();
  }
}
```

- `textureSpace: "global"` measures the texture in the shape's own
  coordinates, so the pattern repeats every `texture.source.width` pixels no
  matter how large the shape is. A texture from `createTexture` is its own
  source, so that is `texture.width`. A frame cut from a spritesheet fills with
  the whole sheet, not the frame. The default, `"local"`, stretches exactly
  one copy over the shape's bounds.
- `createTexture(draw, { width, height })` bakes exactly that region, measured
  from `(0, 0)`. Without the size the texture is the drawn bounds, so its
  top-left corner is the first pixel drawn: a drawing that starts at `(10, 4)`
  bakes shifted by that much, and the pattern repeats over the smaller size.
- A standalone `g.texture(tex, tint, x, y)` call draws at the alpha of the
  active fill style, so a preceding `fill({ alpha: 0 })` makes it invisible.
  Pass the size to `createTexture` rather than a transparent rectangle when the
  point of the rectangle was to pin the texture's bounds. A
  `.fill({ texture })` call keeps its own alpha, because a fill style merges
  against pixi's default rather than the active one.
- `matrix` moves the pattern under the shape. `new Matrix().translate(-x, 0)`,
  redrawn each frame with a growing `x`, scrolls it sideways. `Matrix` is
  imported from `pixi.js`, like the `Graphics` object `.draw()` hands you.
- There is no wrap mode to set. Pixi switches the fill texture's address mode
  to `repeat` when it builds the geometry, so `texture.source.addressMode`
  needs no help.
- Never call `texture.source.update()` on a texture from `createTexture`. That
  texture's pixels live only on the GPU, so the re-upload `update()` triggers
  writes an empty image over them. Everything drawn from the texture then
  renders transparent, permanently.

## Camera

The camera is an entity, not a service. Spawn a `CameraEntity` in your scene
and use it directly for follow, shake, zoom, and bounds — all convenience methods are on the entity.

`CameraEntity` composes five components, each exported from the package for a
hand-built camera:

| Component               | What it does                                                                                                                    |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `CameraComponent`       | Holds the camera's `position`, `rotation`, `zoom`, viewport size and layer bindings, plus the `modifiers` list                  |
| `CameraFollow`          | Moves `CameraComponent.position` toward a target's world position each frame, with smoothing, an offset and a deadzone          |
| `CameraBoundsComponent` | Clamps `CameraComponent.position` into a rectangle after every other camera behaviour ran, using this frame's position and zoom |
| `CameraShake`           | Adds one removable displacement to `CameraComponent.modifiers` for a duration, leaving the base position where it was           |
| `CameraZoom`            | Interpolates `CameraComponent.zoom` toward a target over a duration, through an easing function                                 |

`CameraEntity` and `CameraComponent` both expose the shorter route to the same
behaviour: `follow`/`unfollow`/`snapToTarget` reach `CameraFollow`, the `bounds`
accessor reaches `CameraBoundsComponent`, `shake` reaches `CameraShake`, and
`zoomTo` reaches `CameraZoom`. Reach for a behaviour component directly to read
its own state, such as `CameraShake.offset`.

```ts yage-context="scene-enter"
import { Entity, Transform, easeOutQuad } from "@yagejs/core";
import { CameraEntity } from "@yagejs/renderer";

class Player extends Entity {
  setup() {
    this.add(new Transform());
  }
}

// In a scene's onEnter():
const player = this.spawn(Player);
const cam = this.spawn(CameraEntity, {
  follow: player.get(Transform),
  smoothing: 0.1,
  offset: { x: 0, y: -50 },
  deadzone: { halfWidth: 20, halfHeight: 20 },
  snap: true, // start on the target; without it the camera eases in from (0, 0)
});

cam.snapToTarget(); // cut to the target after a teleport (room change, respawn)
cam.unfollow();

cam.shake(10, 0.5, { decay: 1 }); // duration in seconds; decay 1 fades to zero by the end, 0 (default) holds full strength
cam.zoomTo(2.0, 1, easeOutQuad); // duration in seconds

const modifier = cam.modifiers.add({
  position: { x: 4, y: 0 }, // additive
  rotation: 0.02, // additive
  zoom: 1.05, // multiplicative
});
modifier.remove();

cam.bounds = { minX: 0, minY: 0, maxX: 2000, maxY: 1000 };

const world = cam.screenToWorld(400, 300); // virtual viewport px → world px
const { x, y } = player.get(Transform).worldPosition;
const screen = cam.worldToScreen(x, y); // world px → virtual viewport px
```

`effectivePosition`, `effectiveRotation`, and `effectiveZoom` combine the
camera's base values with every active `CameraModifierHandle`. Layer rendering
and coordinate conversion use these effective values. Removing a handle does
not restore a snapshot or affect other modifiers. Modifiers are transient.

`screenToWorld` / `worldToScreen` use the camera's own transform, not any layer's. A layer bound with a ratio below `1` (parallax, dampened zoom) renders under a different transform, so the result does not name a point on that layer. `screenToWorld`'s result is undefined at a zoom of `0`.

`CameraComponent` and `CameraEntity` also accept caller-owned `Vec2Buffer`
outputs from `@yagejs/core`:

```ts
import type { Vec2Buffer } from "@yagejs/core";
import { CameraComponent as BaseCameraComponent } from "@yagejs/renderer";

// CameraEntity declares the same three methods.
declare class CameraComponent extends BaseCameraComponent {
  getEffectivePositionInto(out: Vec2Buffer): Vec2Buffer;
  screenToWorldInto(
    out: Vec2Buffer,
    screenX: number,
    screenY: number,
  ): Vec2Buffer;
  worldToScreenInto(
    out: Vec2Buffer,
    worldX: number,
    worldY: number,
  ): Vec2Buffer;
}
```

Each method overwrites and returns `out` without constructing a `Vec2`.
Create the buffer once and reuse it for repeated queries. The coordinates
match the immutable queries, including active modifiers. Screen coordinates
are virtual viewport pixels; world coordinates are world pixels. A buffer
does not follow camera changes; query again to refresh it. Use the immutable
queries for values you retain or share.

`shake(intensity, duration)` measures `intensity` in **world pixels per axis**, so the on-screen displacement scales with zoom — the same intensity moves twice as far at zoom 2. `CameraFollowOptions.offset` and `deadzone` are world pixels too. `smoothing: 0` never moves the camera, so the follow appears frozen.

`follow(target)` takes a `FollowTarget`: an `Entity`, a `Transform`, a world point, or a function returning one. `Entity` and `Transform` are read through `worldPosition`, so a target parented under a moving platform tracks where it actually is. `smoothing` must be finite and `>= 0`, `zoomTo`'s target finite and `> 0`, and a `fitTo` rect's `width`/`height` finite and `> 0` — each throws at the call naming the offending value.

### Follow smoothing and `snap`

`smoothing` is `1` by default (the camera reaches its target position every frame). Any value below `1` eases toward that position from the camera's current one, so a camera spawned at the default `(0, 0)` glides in from the world origin over the first frames of the scene.

`snap: true` — on `CameraEntity` params and on `CameraFollowOptions` — places the camera on the target as following starts, offset included. `snapToTarget()` (on `CameraEntity`, `CameraComponent`, and `CameraFollow`) performs the same cut on demand. Both skip the deadzone once; it applies again from the next frame. `snapToTarget()` does nothing when no target is set.

### Coordinate Convention

Camera position `(0, 0)` places the **world origin at the center of the viewport**, not the top-left. Entities rendered at `(0, 0)` appear centered. This is the standard convention for camera-driven 2D games (scrolling shooters, platformers).

For top-left-origin games (tilemap editors, classic arcade layouts), offset the camera by half the viewport so that world `(0, 0)` aligns with the screen's top-left corner — or use `fitTo` (below) to frame the whole level in one call.

```ts
import { Scene, Vec2 } from "@yagejs/core";
import { CameraEntity } from "@yagejs/renderer";

class GameScene extends Scene {
  readonly name = "game";

  onEnter() {
    // Top-left-origin: world (0,0) maps to screen (0,0)
    this.spawn(CameraEntity, { position: new Vec2(400, 300) }); // viewport is 800×600
  }
}
```

### `fitTo` — frame a world rectangle

`fitTo: { x, y, width, height }` is the fixed-camera primitive: it positions the camera at the rect's centre AND sets `zoom` so the entire rect fits inside the viewport (`contain` semantics, `zoom = min(viewportW / rect.w, viewportH / rect.h)`). Overrides explicit `position` and `zoom` when supplied. Applied once at setup against the renderer's current `virtualSize`.

Use for puzzle boards, arcade-style single-screen layouts, dialog-scene insets — anywhere the framed area is known up front. Pair with no `follow` and the camera never moves; pair with `follow` and the camera starts framing the rect, then tracks the target from there.

```ts yage-context="scene-enter"
import { CameraEntity } from "@yagejs/renderer";

this.spawn(CameraEntity, {
  fitTo: { x: 0, y: 0, width: 800, height: 600 },
});
```

For runtime re-framing, set `position` and `zoomTo()` directly on the camera — `fitTo` is a one-shot, not a responsive binding.

## Render Layers

Layers are declared per scene and materialized by the renderer's
`beforeEnter` hook into a `SceneRenderTree` registered on scene scope.

```ts
import type { LayerDef } from "@yagejs/renderer";
import { Scene } from "@yagejs/core";

class GameScene extends Scene {
  readonly name = "game";
  readonly layers: readonly LayerDef[] = [
    { name: "background", order: -10 },
    { name: "world", order: 0 },
  ];
}
```

**The `"default"` layer.** Every scene's tree auto-creates a layer named `"default"` at order 0; any sprite/text/graphics with no explicit `layer` renders there. Declaring `{ name: "default", ... }` _configures_ that pre-created layer (its `sort` / `space` / `isRenderGroup`) rather than adding a second one — `{ name: "default", order: 0, sort: ySort }` is the canonical "depth-sort the layer my entities already use" setup, without setting `layer` on each component. `order` is required by `LayerDef` but ignored for `"default"` (default is order 0 by definition). To change a live layer's sort after the scene is running, call `layer.setSort(fn)` — `tree.defaultLayer.setSort(ySort)`. Passing `undefined` stops the per-frame re-sort but does **not** restore the original insertion order (Pixi reorders `children` in place; clearing `sortableChildren` just halts further sorting), so children keep their last-sorted order.

**Moving a visual between layers.** `component.layerName` is the layer a visual draws on, and `component.setLayer(name)` moves it: the render object is detached and re-parented through the same resolution the initial add uses, so a `SortGroupComponent` on the new layer still claims it. Called before the component is added to an entity it only records the name. The visual joins its new parent last, which on a layer with no `sort` means it draws in front of everything already there.

**Undeclared `layer` name.** A visual component (`SpriteComponent`, `GraphicsComponent`, etc.) whose `layer` names a layer the scene never declared emits a dev-mode `[yage]` warning (naming the entity, the missing layer, and the scene) and falls back to the `"default"` layer — the visual still renders, just on the wrong layer. The fix is to add `{ name: "<layer>", order: N }` to the scene's `layers`.

### Ensuring a layer

`tree.ensureLayer(def, options?)` creates a missing layer and otherwise returns
the existing layer without changing its order. In development, an order
mismatch warns once per scene tree, layer name, and requested order. Repeated
matching requests are silent. Addon presenters use this same contract for
their configured layer names.

### Camera binding rule

A `CameraEntity` auto-binds every world-space layer in the scene tree
(`LayerDef.space === "world"`, the default), including layers created after
the camera spawned. Declare a layer with `space: "screen"` to keep it fixed
to the viewport — cameras skip it on auto-bind.

```ts
import { Scene } from "@yagejs/core";
import type { LayerDef } from "@yagejs/renderer";

class GameScene extends Scene {
  readonly name = "game";
  readonly layers: readonly LayerDef[] = [
    { name: "background", order: -10 }, // world-space (default)
    { name: "world", order: 0 }, // world-space
    { name: "hud", order: 100, space: "screen" }, // screen-space HUD
  ];
}
```

Plugins auto-provision screen-space layers via
`tree.ensureLayer(def, { space: "screen" })`. The UI packages
(`@yagejs/ui`, `@yagejs/ui-react`) do this for their `"ui"` layer, so a
bare `new UISurface()` stays pinned to the viewport under the default
camera.

Diegetic UI (entity-anchored prompts, health bars, damage numbers) is a
legitimate use case: declare a world-space layer and parent a
`UISurface({ layer: "..." })` into it — the panel's container scrolls and
zooms with the camera.

To override: pass `bindings` on the camera. Each entry replaces the
auto-binding for the layer it names, so one entry buys one parallax layer
and every other world layer keeps following at full strength. An entry
naming a screen-space layer adds it.

```ts yage-context="scene-enter"
import { CameraEntity } from "@yagejs/renderer";

// "sky" drifts at a tenth of the camera; every other world layer follows.
this.spawn(CameraEntity, {
  bindings: [{ layer: "sky", translateRatio: 0.1 }],
});
```

`autoBind: false` drops the auto-bound set, so the camera drives exactly
the layers `bindings` names. That is the only way to leave a world-space
layer untransformed — a binding with all three ratios at `0` centres the
layer on the viewport instead of leaving it alone.

```ts yage-context="scene-enter"
import { CameraEntity } from "@yagejs/renderer";

// Drives "sky" and "world" only; the world-space "minimap" layer, left out
// of the list, stays untransformed.
this.spawn(CameraEntity, {
  autoBind: false,
  bindings: [{ layer: "sky", translateRatio: 0.1 }, { layer: "world" }],
});
```

### `LayerDef.sort` — per-frame paint order

Default paint order within a layer is **insertion order** — sprites render in the order their containers were added. Set `LayerDef.sort` to a **depth-key function** `(container) => number` and `DisplaySystem` writes the result to `container.zIndex` for every child each frame; Pixi's render pipeline then orders the layer by zIndex. The hook also flips `container.sortableChildren = true` so Pixi knows to honour the zIndex.

Two built-in helpers cover the common cases:

| Helper              | Returns                                                                                                                                                                                                                                                 |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ySort`             | `c.position.y` — classic top-down depth, characters with higher y paint on top.                                                                                                                                                                         |
| `ySortBy(offsetOf)` | `c.position.y + offsetOf(c)` — each container can provide a per-sprite Y offset (Godot's `y_sort_origin`) so the depth key tracks the visual "footprint" instead of the top-left. `offsetOf` returns `undefined` to fall through to plain `position.y`. |

```ts
import { Scene } from "@yagejs/core";
import { ySort, ySortBy, type LayerDef } from "@yagejs/renderer";

class GameScene extends Scene {
  readonly name = "game";
  readonly layers: readonly LayerDef[] = [
    { name: "ground", order: -10 },
    { name: "characters", order: 0, sort: ySort },
  ];
}

// Per-sprite offset variant — read off a custom field on the display object:
const sort = ySortBy((c) => (c as { depthOffset?: number }).depthOffset);
```

Game code that manually writes `child.zIndex` on individual sprites doesn't need `sort` — once `sortableChildren` is on, Pixi sorts them. `sort` is for the common case where the depth key is a function of the sprite's current state (position, depth offset) and needs to be recomputed each frame. The two paths compose: a `sort` fn handles the bulk of a layer, and individual sprites can still write their own `zIndex` between updates to bias themselves above or below the depth key.

### `SortGroupComponent` — keep a multi-part entity from splitting

Under a layer `sort`, every visual is a flat child of the layer with its own independent depth key. So a multi-part entity — a body plus an offset child sprite (held item, mount, floating crystal) — can be **split**: an unrelated entity whose key falls between the parts renders _between_ them. (Unity's `SortingGroup`, Godot's nested y-sort scopes.)

`SortGroupComponent` gives an entity its own Pixi sub-container. Its members sort _within_ the group; the group sorts as **one unit** against the rest of the layer.

```ts
import { Entity, Transform } from "@yagejs/core";
import { SortGroupComponent, SpriteComponent } from "@yagejs/renderer";

class Weapon extends Entity {
  setup() {
    this.add(new Transform({ position: { x: 12, y: 4 } }));
    this.add(new SpriteComponent({ texture: "sword", layer: "world" }));
  }
}

class Plume extends Entity {
  setup() {
    this.add(new Transform({ position: { x: 0, y: -16 } }));
    this.add(new SpriteComponent({ texture: "plume", layer: "world" }));
  }
}

class Knight extends Entity {
  setup() {
    this.add(new Transform({ position: { x: 200, y: 200 } }));
    this.add(new SortGroupComponent({ layer: "world" })); // add BEFORE the visuals
    this.add(new SpriteComponent({ texture: "knight-body", layer: "world" }));
    this.spawnChild("weapon", Weapon); // a "world" sprite offset toward the camera
    this.spawnChild("plume", Plume);
  }
}
```

`new SortGroupComponent(options?)`:

| Option      | Meaning                                                                                                                                                                                                                                                                                                          |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `layer`     | Layer the group renders into (default `"default"`). Subtree visuals targeting **this same layer** are gathered in; visuals on other layers are left alone (a child's shadow can stay on a separate `"ground"` layer).                                                                                            |
| `innerSort` | Depth key for ordering the group's own members. Default (unset): members keep **insertion order**, and a member's manually-set `zIndex` is honoured (a real stacking context — like Unity's `SortingGroup`). Pass `ySort` to order members by position among themselves while the group still sorts as one unit. |

Semantics:

- **Sort key** — the group sorts in the layer by the owning entity's _own_ sprite (so `ySort`/`ySortBy` read a real sprite's position/offset). A group-owning entity with no sprite of its own falls back to a proxy at its `Transform` world position — fine for a purely-logical parent that just groups children.
- **Transforms are untouched.** The group container stays at identity/origin; members keep their normal world transforms. Adding a group changes paint **order** only — never position, rotation, or scale (those stay composed by the ECS `Transform`). Rotating the parent rotates the children exactly as before.
- **Add the group before the visuals it should capture.** It also re-homes any already-present subtree visuals when added late.
- A `SortGroupComponent` on a _descendant_ entity starts its own independent unit rather than nesting inside the ancestor's. Sort grouping and transform parenting are independent axes.

Tradeoff: a grouped entity's parts no longer individually interleave with the world — the whole entity sorts at one key. That's the point (parts stay welded), but it means a tall entity can't have its base pass behind a tree while its top passes in front. Group only the entities that need to stay coherent.

### `LayerDef.isRenderGroup` — Pixi render-group opt-in

`isRenderGroup: true` promotes the layer's container to a Pixi v8 render
group. Render groups render as a separate pass with their own instruction
set and have their transforms handled on the GPU, which can be useful for
isolating large, slow-changing subtrees from per-frame transform updates.

Default: `false`. Render groups carry a small fixed cost (their own
render pass + instruction set) — only flip on layers where you've
measured a benefit.

```ts
import { Scene } from "@yagejs/core";
import type { LayerDef } from "@yagejs/renderer";

class GameScene extends Scene {
  readonly name = "game";
  readonly layers: readonly LayerDef[] = [
    { name: "ground", order: -10 },
    { name: "actors", order: 0, isRenderGroup: true },
    { name: "hud", order: 100, space: "screen" },
  ];
}
```

**Not** required for filter isolation around tilemaps. `@yagejs/tilemap`'s
`TilemapPlugin` already patches `@pixi/tilemap`'s `TilemapPipe` so a
filtered sibling layer no longer causes the canopy to drift, regardless
of render-group configuration. See `packages/tilemap/src/patch-tilemap-pipe.ts`.

### CameraBinding — per-axis ratios

Each binding has three independent ratios, all defaulting to `1` (full
camera effect). `0` ignores that axis of the camera; values in between
blend linearly.

```ts
import type { CameraBinding as BaseCameraBinding } from "@yagejs/renderer";

interface CameraBinding extends BaseCameraBinding {
  layer: string;
  translateRatio?: number; // 1 = follow camera position, 0 = stay at world origin
  rotateRatio?: number; // 1 = rotate with camera,      0 = stay upright
  scaleRatio?: number; // 1 = zoom with camera,        0 = constant size
}
```

These are **layer-level decoupling primitives** — useful for parallax,
minimaps, and decoupled HUDs. They are **not** the right answer for
entity-anchored UI like nameplates or health bars: partially ignoring
the camera transform on one layer while the main scene takes the full
transform separates the UI from its target under zoom. For that, see
`ScreenFollow` below.

Recipes:

```ts
import type { CameraBinding } from "@yagejs/renderer";

// Parallax (translate-dampened)
const parallax: CameraBinding = { layer: "background", translateRatio: 0.5 };

// Camera-agnostic minimap (ignores every camera axis; the layer origin
// sits at the viewport centre)
const minimap: CameraBinding = {
  layer: "minimap",
  translateRatio: 0,
  rotateRatio: 0,
  scaleRatio: 0,
};

// One parallax layer on top of the auto-bound set — the whole `bindings`
// array for a scene where every other world layer follows at ratio 1
const bindings: CameraBinding[] = [{ layer: "clouds", translateRatio: 1.4 }];
```

### `syncCameraTransform`

Apply a camera pose to a custom display container:

```ts yage-context="entity"
import { Container } from "pixi.js";
import { CameraComponent, syncCameraTransform } from "@yagejs/renderer";

const container = new Container(); // a container your own tool owns
const cameraComponent = entity.get(CameraComponent); // `entity` is the camera

syncCameraTransform(container, cameraComponent);
syncCameraTransform(container, cameraComponent, {
  layer: "background",
  translateRatio: 0.5,
});
```

Signature: `syncCameraTransform(target: DisplayContainer,
camera?: CameraComponent, binding?: CameraBinding): void`.
Reads `effectivePosition`, `effectiveZoom` and `effectiveRotation`, including
modifiers. Omitted binding ratios are `1`. Omitted camera resets position to
`(0, 0)`, scale to `(1, 1)` and rotation to `0`. The helper writes only the
target's transform; it does not attach the target to a layer or apply the
renderer fit transform. `DisplaySystem` uses the same calculation for layers.

## ScreenFollow

Component. Each frame projects a world source through a camera and writes the resulting screen coord to this entity's `Transform.worldPosition`. The canonical billboard primitive — pair with `UISurface`/`UIRoot` on a screen-space layer using `positioning: "transform"` and the UI tracks the target while staying axis-aligned and constant-size under any camera zoom or rotation.

```ts
import { Entity, Transform, Vec2 } from "@yagejs/core";
import { ScreenFollow, type CameraEntity } from "@yagejs/renderer";
import { UISurface, Anchor } from "@yagejs/ui";

class Nameplate extends Entity {
  setup(params: { target: Entity; camera: CameraEntity }) {
    this.add(new Transform());
    this.add(
      new ScreenFollow({
        target: params.target, // Entity | Vec2Like | () => Vec2Like
        camera: params.camera, // required — no global "main" camera
        offset: new Vec2(0, -40), // screen-pixel offset (applied after projection)
        trackRotation: false, // default: don't copy target's rotation
      }),
    );
    const panel = this.add(
      new UISurface({
        positioning: "transform", // reads Transform.worldPosition each frame
        anchor: Anchor.BottomCenter, // pivot on the panel
      }),
    );
    panel.text("Grunt-42", { fontSize: 11, fill: 0xffffff });
  }
}
```

`target` accepts:

- `Entity` — reads its current `worldPosition` each frame.
- `Vec2Like` — a fixed world coord.
- `() => Vec2Like` — computed each frame (useful for midpoints of two entities, paths, etc.).

`offset` is in **screen pixels**, applied _after_ projection: `cam.worldToScreen(target) + offset`. The visual gap between UI and target stays fixed under any camera zoom or rotation. Rotation is optional: set `trackRotation: true` when `target` is an `Entity` to copy its `worldRotation` (useful for UI that should rotate with the target itself, like a vehicle HUD).

```ts yage-context="component"
import { Scene } from "@yagejs/core";
import { Graphics } from "pixi.js";
import { SceneRenderTreeKey } from "@yagejs/renderer";
import { crt } from "@yagejs/effects";

const myDisplayObject = new Graphics().circle(0, 0, 8).fill(0xffffff);

// Inside a Component:
const tree = this.use(SceneRenderTreeKey);
const layer = tree.get("world");
layer.container.addChild(myDisplayObject);

// Also resolvable from anything holding the scene (onEnter onward) — Scene.use is
// scope-aware, so a scene-scoped effect/mask can be attached at setup:
class MyScene extends Scene {
  readonly name = "my-scene";

  onEnter() {
    this.use(SceneRenderTreeKey).fx.addEffect(crt({}));
  }
}
```

Don't use `SceneRenderTreeProviderKey` from game code — it's tooling-only
(Inspector/debug tools enumerate trees across scenes). Resolve the tree for
the current scene with `this.use(SceneRenderTreeKey)`.

`SpriteComponent`/`GraphicsComponent`/etc. take a `layer` option and handle
this internally. DisplaySystem syncs `Transform` to PixiJS display objects
each Render phase and applies camera + virtual-resolution scaling to the
world root.

## DisplaySystem

Runs in `Phase.Render`. Syncs entity `Transform` to PixiJS display object positions, applying camera offset and zoom.

## Scene Transitions

Built-in visual transitions. Use with `SceneManager.push/pop/replace({ transition })`.

```ts yage-context="engine"
import { Scene } from "@yagejs/core";
import { crossFade, fade, flash } from "@yagejs/renderer";

class Level extends Scene {
  readonly name = "level";
}

await engine.scenes.push(new Level(), { transition: fade({ duration: 0.4 }) });
await engine.scenes.push(new Level(), {
  transition: crossFade({ duration: 0.5 }),
});
await engine.scenes.pop({
  transition: flash({ duration: 0.2, color: 0xff0000 }),
});
await engine.scenes.replace(new Level(), {
  transition: crossFade({ duration: 0.5 }),
});
```

| Export              | Signature                                                                            | Description                                                                                                                       |
| ------------------- | ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
| `fade`              | `(opts?: { duration?: number; color?: number }) => SceneTransition`                  | Fade to color and back (triangle alpha ramp). Incoming scene hidden until mid-point. Default: 0.3s, black.                        |
| `flash`             | `(opts?: { duration?: number; color?: number }) => SceneTransition`                  | Flash overlay decaying from full to zero alpha. Incoming scene revealed under the bright part of the flash. Default: 0.2s, white. |
| `crossFade`         | `(opts?: { duration?: number }) => SceneTransition`                                  | Cross-dissolve between scenes (outgoing alpha 1→0 while incoming alpha 0→1). Default: 0.4s.                                       |
| `getSceneContainer` | `(ctx: SceneTransitionContext, scene: Scene \| undefined) => Container \| undefined` | Helper for custom transitions — resolves a scene's PIXI root container.                                                           |

`fade` and `flash` add a stage-level `Graphics` overlay during the transition and clean up on `end()`. `crossFade` manipulates per-scene containers directly via `getSceneContainer`.

## Effects

Handle-based filter API. Same shape at four scopes — component, layer, scene, screen — exposed uniformly as `.fx` at every scope. The renderer ships only the primitives; pre-built presets live in `@yagejs/effects`.

```ts yage-context="component"
import {
  rawFilter,
  RendererKey,
  SceneRenderTreeKey,
  SpriteComponent,
} from "@yagejs/renderer";
import { hitFlash, bloom, colorGrade, crt, vignette } from "@yagejs/effects";

// Inside a Component on an entity with a sprite.
const sprite = this.entity.get(SpriteComponent);

// Component scope (Sprite / Graphics / Text / AnimatedSprite)
const flash = sprite.fx.addEffect(hitFlash({ color: 0xffffff }));
flash.trigger();
flash.fadeOut(0.2); // seconds; returns a Process

// Layer scope
this.use(SceneRenderTreeKey)
  .get("world")
  .fx.addEffect(bloom({ threshold: 0.8 }));

// Scene scope (the per-scene root)
this.use(SceneRenderTreeKey).fx.addEffect(crt({ lineContrast: 0.4 }));

// Screen scope (cross-scene; on app.stage)
this.use(RendererKey).fx.addEffect(vignette({ alpha: 0.4 }));

// Recover a handle by its named definition.
const existing = sprite.fx.findEffect(hitFlash); // EffectHandle | null

// A set of layers behind one handle — the world layers but not the HUD.
const grade = this.use(SceneRenderTreeKey).addLayerEffect(
  colorGrade({ preset: "night" }),
  ["background", "world", "props"],
);
grade.fadeOut(1); // fans out to all three

// One visual, out of every layer- and scene-scope effect and mask
// (the `renderAboveEffects: true` option sets the same at construction).
sprite.renderAboveEffects = true;
```

| Export                     | Signature                                                                            | Description                                                                                                                                                                                                                                                                                                                                                                                      |
| -------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `.fx` (on every scope)     | `EffectsHost`                                                                        | Per-scope holder. `addEffect(factory)`, `findEffect(definition)`, `destroy()`, `size`. The underlying `EffectStack` is built lazily on first attach.                                                                                                                                                                                                                                             |
| `EffectsHost`              | class                                                                                | Constructor: `(getContainer: () => Container, scope: EffectScope, makeQueue: (() => ScopedProcessQueue) \| undefined)`. Auto-built on each scope's host object — components, layers, scenes, the renderer.                                                                                                                                                                                       |
| `EffectHandle`             | interface                                                                            | `remove()` / `setEnabled(on)` / `enabled` / `setIntensity(value)` / `fadeIn(duration): Process` / `fadeOut(duration): Process` / `run(p: Process): Process`. `setIntensity` clamps to 0–1 and controls the effect's primary intensity. `run` schedules a `Process` scoped to the effect's lifetime — pauses with the owning scene, time-scales with it, auto-cancels when the effect is removed. |
| `Effect.onActivate?(base)` | optional factory hook                                                                | Runs once after `buildExtras` has merged its keys onto the handle. Use to self-schedule per-effect tickers via `base.run(...)` so callers don't have to call `step(dt)` themselves (e.g. CRT noise animator). `buildExtras` itself stays pure — no side effects there.                                                                                                                           |
| `defineEffect`             | `<H, O>({ name, factory: (opts: O) => Effect<H> }) => (opts: O) => EffectFactory<H>` | Define a reusable named preset with typed options.                                                                                                                                                                                                                                                                                                                                               |
| `rawFilter`                | `(filter: Filter, opts?: { intensity?: { get, set } }) => EffectFactory`             | Escape hatch for any pixi `Filter`. Without `intensity`, fade calls no-op + warn once.                                                                                                                                                                                                                                                                                                           |
| `EffectStack`              | class                                                                                | Internal stack owned by `EffectsHost`.                                                                                                                                                                                                                                                                                                                                                           |
| `tree.addLayerEffect`      | `<H>(factory: EffectFactory<H>, layers: readonly string[]) => H`                     | One effect over several layers, controlled through one handle. The factory runs once per layer, so this is one filter pass per listed layer — for a single layer use `tree.get(name).fx.addEffect`. Handle methods fan out; values (`enabled`, an extra's return value) come from the first listed layer. Throws on an empty list or an unknown layer name, before attaching anything.           |
| `tree.renderAboveEffects`  | `(node: DisplayContainer) => void`                                                   | Draw `node` after the scene's layers, outside every layer- and scene-scope effect and mask, keeping its logical parent. `tree.renderWithEffects(node)` undoes it. Visual components expose the same thing as the `renderAboveEffects` option; use these for a display object the game parents in itself.                                                                                         |

**Filter ordering:** pixi processes filters bottom-up the display tree — component → layer → scene → screen. Each outer scope sees the previous scope's rasterized output, so screen-scope `pixelate` will pixelate already-bloomed gameplay.

**Layer-scope coordinate space:** layer / scene / screen filters operate on screen-space pixels post-camera-transform. A bloom radius is in screen pixels, not world units.

**Lifted visuals:** `renderAboveEffects` puts a visual outside layer and scene filters, `layer.setMask`, `tree.setMask`, and the `irisReveal` / `chessboard` transition masks (it shows at once during those reveals). A screen-scope effect still covers it, and hit testing follows the logical tree, so a UI element drawn under it still receives the pointer first. Draw order among lifted visuals is attach order. On a `SortGroupComponent` the flag applies to the component's own render object, not the group container. A layer declared with `isRenderGroup: true` is unsupported for lifted visuals (a Pixi restriction).

**Lifecycle:**

- Component effects: torn down in the visual component's `onDestroy`. Fades pause + time-scale with the entity's scene.
- Layer / scene effects: torn down on scene exit. Fades pause + time-scale with that scene.
- Screen effects: torn down on `RendererPlugin.onDestroy` (engine teardown). Fades run in engine time, do NOT pause across scenes.

## Masks

```ts yage-context="component"
import {
  SceneRenderTreeKey,
  SpriteComponent,
  rectMask,
  spriteMask,
  graphicsMask,
} from "@yagejs/renderer";

const sprite = this.entity.get(SpriteComponent);
const tree = this.use(SceneRenderTreeKey);

// Component scope (4 visual components)
const handle = sprite.setMask(
  rectMask({ x: 0, y: 0, width: 200, height: 200 }),
);
handle.setInverse(true);
sprite.clearMask();

// Layer + scene scope share the same setMask / clearMask shape.
tree.get("hud").setMask(rectMask({ x: 0, y: 0, width: 800, height: 64 }));
tree.setMask(
  graphicsMask((g) => {
    g.circle(0, 0, 100).fill(0xffffff);
  }),
);
```

| Export                                  | Signature                                                               | Description                                                                                                                                                                                                                            |
| --------------------------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `setMask` (component / layer / scene)   | `(factory: MaskFactory) => MaskHandle`                                  | Replace any existing mask. Handle owns the new mask's lifecycle.                                                                                                                                                                       |
| `clearMask` (component / layer / scene) | `() => void`                                                            | Detach + destroy the current mask.                                                                                                                                                                                                     |
| `MaskHandle`                            | interface                                                               | `remove()` / `setInverse(on)` / `inverse` / `redraw()`.                                                                                                                                                                                |
| `rectMask`                              | `(opts: RectMaskOptions) => MaskFactory`                                | Static rectangle, optional `rounded` corners.                                                                                                                                                                                          |
| `spriteMask`                            | `(sprite: Sprite) => MaskFactory`                                       | User-owned sprite as mask.                                                                                                                                                                                                             |
| `graphicsMask`                          | `(draw: (g: Graphics) => void) => MaskFactory`                          | Custom drawn mask; call `handle.redraw()` after dependencies change. The closure must `g.clear()` first (pixi commands accumulate) and read live state from a captured object/getter — `const` snapshots stay stale across `redraw()`. |
| `defineMask`                            | `<O>({ name, factory: (opts: O) => Mask }) => (opts: O) => MaskFactory` | Define a reusable named mask preset.                                                                                                                                                                                                   |
| `attachMask`                            | low-level helper                                                        | `attachMask(target, factory, owner?)` returns a `MaskHandle`. Pass the owning `Component` as `owner` so a throw from the draw callback is attributed to it in `Inspector.getErrors().callbackErrors`.                                  |

Mask coordinates are the masked object's own local space: world pixels on a world layer, where the mask scrolls with the camera, and virtual pixels on a screen layer, where it stays put.

## Offscreen render targets

`renderer.createRenderTarget(source, options)` draws a container into a texture the game owns and redraws on its own schedule. Use it when several objects must composite against each other before reaching the screen (a light buffer, a trail buffer, a downscaled blur source), or to cache expensive static content as one texture.

```ts yage-context="scene-enter"
import { Component, Transform } from "@yagejs/core";
import { Container, Graphics } from "pixi.js";
import {
  RendererKey,
  SpriteComponent,
  registerTexture,
  unregisterTexture,
} from "@yagejs/renderer";
import type { RenderTargetHandle } from "@yagejs/renderer";

// One component owns all three resources — the source container, the buffer
// and the registered key — and frees them in onDestroy.
class LightBuffer extends Component {
  private readonly source = new Container();
  private readonly hole = new Graphics().circle(0, 0, 120).fill(0xffffff);
  private target!: RenderTargetHandle;

  /** Registered keys are engine-global, so give each buffer its own. */
  constructor(private readonly key: string) {
    super();
  }

  onAdd(): void {
    const darkness = new Graphics()
      .rect(0, 0, 1280, 720)
      .fill({ color: 0x05060a, alpha: 0.85 });
    this.hole.blendMode = "erase"; // cuts the darkness INSIDE the buffer
    this.source.addChild(darkness, this.hole); // never added to the scene tree

    this.target = this.use(RendererKey).createRenderTarget(this.source, {
      width: 1280,
      height: 720,
      resolutionScale: 0.5, // quarter the texels; invisible on soft gradients
    });
    registerTexture(this.key, this.target.texture);
  }

  /** Move the lit spot. Coordinates are buffer pixels, not world pixels. */
  moveLight(x: number, y: number): void {
    this.hole.position.set(x, y);
    this.target.invalidate();
  }

  update(): void {
    this.target.renderIfNeeded(); // draws only when a redraw is pending
  }

  onDestroy(): void {
    unregisterTexture(this.key); // the key outlives the texture
    this.target.destroy(); // frees the buffer's GPU memory
    this.source.destroy({ children: true }); // frees the offscreen content
  }
}

// In a Scene. The owner is added first, so the key resolves for the sprite.
const LIGHTING = "arena:lighting";

const lights = this.spawn("lights");
lights.add(new Transform());
lights.add(new LightBuffer(LIGHTING));
lights.add(new SpriteComponent({ texture: LIGHTING, layer: "overlay" }));
```

Destroying that entity runs `onDestroy` and releases all three; so does
exiting the scene, which destroys its entities. Own the buffer on a scene's
`onEnter` / `onExit` pair instead when its lifetime is exactly the scene's.

Give each buffer its own key rather than a shared constant. Registrations are
engine-global, and a `replace` transition keeps both scenes alive at once, so
two scenes registering `"lighting"` leave one `unregisterTexture` call to
remove the other scene's entry. Every later lookup of that key then throws.

| Member                                   | Signature                                                                        | Description                                                                                                                                                                                                                                                                                              |
| ---------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `RendererPlugin.createRenderTarget`      | `(source: DisplayContainer, options: RenderTargetOptions) => RenderTargetHandle` | Allocate the buffer. The repeatable counterpart of `createTexture`, which bakes once and never changes.                                                                                                                                                                                                  |
| `RenderTargetOptions`                    | `{ width, height, resolutionScale?, antialias?, clearColor?, label? }`           | `width` / `height` are in source coordinates. `resolutionScale` (default `1`) multiplies the renderer's own resolution. `clearColor` defaults to transparent.                                                                                                                                            |
| `handle.texture`                         | `TextureResource`                                                                | What the buffer draws into. Feed it to `SpriteComponent` (via `registerTexture`), `spriteMask`, or a filter uniform.                                                                                                                                                                                     |
| `handle.source`                          | `DisplayContainer`                                                               | The container passed to `createRenderTarget`. `destroy()` leaves it alone — freeing it is the game's job.                                                                                                                                                                                                |
| `handle.render()`                        | `() => void`                                                                     | Draw now and clear the pending flag.                                                                                                                                                                                                                                                                     |
| `handle.renderIfNeeded()`                | `() => boolean`                                                                  | Draw only when pending; returns whether it drew.                                                                                                                                                                                                                                                         |
| `handle.invalidate()`                    | `() => void`                                                                     | Mark the buffer stale.                                                                                                                                                                                                                                                                                   |
| `handle.needsRender`                     | `boolean`                                                                        | Whether a render is pending.                                                                                                                                                                                                                                                                             |
| `handle.resize(w, h, scale?)`            | `(number, number, number?) => void`                                              | Resize and mark stale. Anything showing the texture picks up the new size on its next draw. Omitting `scale` keeps the configured `resolutionScale`, re-derived against the renderer's current resolution; passing one replaces it.                                                                      |
| `handle.width` / `height` / `resolution` | `number`                                                                         | Measured size in source coordinates, and the texels-per-pixel actually allocated.                                                                                                                                                                                                                        |
| `handle.destroy()`                       | `() => void`                                                                     | Free the texture's GPU memory. Repeatable. The source container is untouched — destroy that as well. Afterwards `texture`, `width`, `height`, `resolution`, `render()` and `resize()` throw `RenderTargetHandle.<member>: the handle is destroyed.`; so does `renderIfNeeded()` while a draw is pending. |

Semantics:

- **Coordinate space.** The buffer is drawn in the source container's OWN space: a child at local `(100, 50)` lands at texture pixel `(100, 50)`. Ancestor transforms never reach it, so neither the camera nor the responsive `fit` scale moves or resizes the content. To follow the camera, move the source's children yourself. `camera.position` alone is not enough once zoom, rotation, or shake are in play — run the world point through `camera.worldToScreen(x, y)` and place the child at the result, or size the buffer to `renderer.visibleVirtualRect`.
- **The source's own transform DOES apply.** Setting `source.position` or `source.scale` shifts everything inside the texture. Leave the source untransformed unless that is what you want.
- **Keep the source out of the scene render tree.** Pixi promotes a rendered container to a render group, which changes how it batches wherever it is parented, and content drawn into a buffer is normally shown through the buffer's texture rather than twice.
- **A hidden source draws nothing.** Pixi skips a container with `visible === false`; the pending flag is kept — and set, if the draw was forced — so the buffer catches up when it is shown again.
- **A destroyed source throws.** Once the source container is destroyed the buffer can never draw again, so `render()` and `renderIfNeeded()` throw a named error rather than leaving a permanently stale texture. Destroy the target alongside its source.
- **`resolutionScale` costs sharpness, not layout.** Only the texel count drops. The one exception is rounding: Pixi stores whole texels, so a `width × resolution` that lands between them is rounded up and the measured size grows to match — at `resolutionScale: 0.25` on a resolution-2 renderer, `resize(1279, 719)` measures `1280 × 720`. Use sizes that divide evenly by the effective resolution if the exact measurement matters. Worth it for gradients and glows, not for text or pixel art.
- **One owner frees three things.** A render target is the texture (`handle.destroy()`), the source container (`source.destroy({ children: true })` — `handle.destroy()` never touches it) and any key it was registered under (`unregisterTexture(key)`, or that key keeps resolving a destroyed texture). Put all three in one component's `onDestroy`, which runs when its entity is destroyed or when the scene tears down. A scene's `onEnter` / `onExit` pair is the alternative for a buffer whose lifetime is the whole scene.
- **Cost.** Every `render()` is a full draw of the source plus a render-target switch. A buffer that only changes when the game state does should be invalidated on that change, not every frame. A buffer that tracks moving content pays that cost per frame — `resolutionScale` is the lever there.
- **Backends.** Pixi's default backend order is WebGL first, so a game that doesn't pass `pixi: { preference: "webgpu" }` runs on WebGL. Blend behaviour inside a render target, `"erase"` included, is verified on WebGL and unmeasured on WebGPU.

## Save state

Effects, masks, render trees, display objects, and in-flight fades are runtime
resources. They are not included in `@yagejs/save` state roots. Save the stable
game facts that select them, then recreate them through normal scene and
component setup after load.

## Asset Factories

```ts
import { Scene } from "@yagejs/core";
import {
  texture,
  spritesheet,
  renderAsset,
  bitmapFont,
  webFont,
} from "@yagejs/renderer";

// Returns AssetHandle<Texture> for preloading
const heroTex = texture("hero.png");
// `scaleMode` sets how the loaded image is sampled — one sheet at a time,
// where `pixelArtPreset` switches the whole project.
const tiles = texture("tiles.png", { scaleMode: "nearest" });
const sheet = spritesheet("characters.json");
const asset = renderAsset("ui-atlas.json");
// AssetHandle<BitmapFont> — a BMFont .fnt/.xml + atlas. The loaded font
// registers under the fontFamily in the descriptor; pass that name as
// `style.fontFamily` (with `bitmap: true`) on TextComponent / UIText.
const pixelFont = bitmapFont("fonts/press-start.fnt");
// AssetHandle<FontFace[]> — a plain .ttf/.woff/.woff2 for canvas Text. The
// face registers under `family` (pass that as `style.fontFamily`); omit to let
// Pixi derive it from the file name. Preload it so the face is ready before the
// first draw — Pixi caches fallback metrics on first paint otherwise.
const uiFont = webFont("fonts/Inter.woff2", { family: "Inter" });
// Pass `bitmap` to ALSO bake a BitmapText atlas under the same family, so the
// one declared font works as canvas Text (no `bitmap`) and as a bitmap atlas
// (`bitmap: true`) — see `webFont({ bitmap })` below.
const dualFont = webFont("fonts/Inter.woff2", {
  family: "Inter",
  bitmap: true,
});

// Use in Scene.preload:
class MyScene extends Scene {
  readonly name = "my-scene";
  readonly preload = [heroTex, sheet, pixelFont, uiFont];
}
```

### Runtime textures

**`registerTexture(key, texture)` / `unregisterTexture(key)`** — register a runtime-created texture under an asset key so every key-based surface resolves it exactly like a preloaded asset: `texture: key` on `SpriteComponent` and on a particle emitter, `{ sheet: key, frameWidth }` on any `FrameSource`.

```ts yage-context="entity,scene-enter"
import { Transform } from "@yagejs/core";
import {
  registerTexture,
  RendererKey,
  SpriteComponent,
  AnimatedSpriteComponent,
} from "@yagejs/renderer";

// One-frame case: draw → register → reference by key.
const renderer = this.context.resolve(RendererKey); // in a Scene
registerTexture(
  "marker",
  renderer.createTexture((g) => g.circle(8, 8, 8).fill(0xff0000)),
);
entity.add(new SpriteComponent({ texture: "marker" }));

// Runtime animation: bake the frames as ONE horizontal strip (x = i * frameWidth),
// register it, and reference it as a strip FrameSource. The size names the
// strip's region, so each 32-pixel slice lands on one circle.
const strip = renderer.createTexture(
  (g) => {
    for (let i = 0; i < 4; i++)
      g.circle(i * 32 + 16, 16, 6 + i * 2).fill(0xffcc00);
  },
  { width: 128, height: 32 },
);
registerTexture("boss-idle", strip);
const boss = this.spawn("boss");
boss.add(new Transform());
boss.add(
  new AnimatedSpriteComponent({
    source: { sheet: "boss-idle", frameWidth: 32 },
  }),
);
```

Semantics:

- Register runtime textures before constructing components that reference their keys. Every key-based lookup — sprite, animation, particle emitter — throws and names the missing key.
- Registered keys are engine-global, outside the asset manager's ref counts, and live until `unregisterTexture(key)`.
- `unregisterTexture` never destroys the texture — the creator owns the GPU resource; call `texture.destroy()` once nothing draws it. No-op for keys it never registered.
- Re-registering a key replaces the entry; components constructed before the replacement keep the old texture instance (resolution happens at construction).
- Registering a key already used by a loaded asset (or any cache entry the API didn't create) throws — shadowing a loaded asset would let that asset's unload destroy the registered texture.
- A runtime texture also works as a graphics fill — `g.rect(...).fill({ texture, textureSpace: "global" })` tiles it across the shape. See "Texture fills". Do not call `update()` on the `source` of a `createTexture` result: that re-uploads the texture empty and it stays blank.
- A frame grid needs the size option on `createTexture`. Without it the texture measures the drawn bounds and its origin is the top-left of the drawing, so `frameWidth` slices land between the shapes rather than on them: four circles of radius 6 to 12 drawn at `x = i * 32 + 16` bake into a 114 x 24 texture whose cells start 10 pixels off. `{ width: 128, height: 32 }` makes the grid line up. A non-finite or zero dimension throws naming the dimension.

**`installBitmapFont(source, opts)`** — bake a bitmap glyph atlas from a `.ttf`/`.woff` at runtime via Pixi v8's `BitmapFont.install`. Returns the registered font name, ready to pass as `style.fontFamily` (with `bitmap: true`):

```ts yage-context="entity"
import { installBitmapFont, TextComponent } from "@yagejs/renderer";

const font = await installBitmapFont("fonts/PressStart2P.ttf", {
  name: "PressStart",
  size: 16, // glyph bake size (default 32)
  resolution: 2, // crisp when upscaled (default 2)
  // chars: [["a","z"],["A","Z"],"0123456789 .,!?"],  // default: alphanumeric
  // style: { fill: 0x00ff00 },                        // bake a fixed colour
});
entity.add(
  new TextComponent({
    text: "READY",
    bitmap: true,
    style: { fontFamily: font, fill: 0xffcc00 },
  }),
);
```

Glyphs bake **white** by default so a per-text `fill` / `tint` (multiplied over the atlas) can recolour them — a black atlas would yield `black × tint = black`. Set `style.fill` only to bake a fixed colour. To recolour at runtime use `mergeStyle({ fill })` so `fontFamily` survives — `setStyle({ fill })` replaces the style and drops the font.

**Teardown — `uninstallBitmapFont(name)`.** Frees the baked atlas (and every variant) plus the source face when a font is no longer rendered; the symmetric counterpart of `installBitmapFont` (without it an install-once atlas lives until the page unloads). Baked bitmap fonts are **reference-counted by family name**, so a family shared by an `installBitmapFont` _and_ a `webFont({ bitmap })` (or two web-font loads) is only destroyed once the **last** owner releases it — `uninstallBitmapFont` and `webFont` unload are safe to interleave on a shared family. (The same family name pointing at two _different_ source fonts still collides in Pixi's global registry — last bake wins — so keep family names unique.)

**Synthetic bold / italic — `variants`.** Plain `BitmapText` ignores `style.fontWeight` / `fontStyle` (only canvas `Text` honours them). Pass `variants` to bake emphasis atlases from the same `.ttf` alongside the base; a `BitmapText` whose style asks for bold/italic then renders from the matching atlas automatically. Variants register under derived names internally — you never name or select them by hand:

```ts
import { installBitmapFont, TextComponent } from "@yagejs/renderer";

await installBitmapFont("fonts/Body.ttf", {
  name: "Body",
  variants: [
    { fontWeight: "bold" }, // → "Body bold"
    { fontStyle: "italic" }, // → "Body italic"
    { fontWeight: "bold", fontStyle: "italic" }, // → "Body bold italic"
  ],
});

// Resolves the bold atlas — no manual font name needed:
new TextComponent({
  text: "HP",
  bitmap: true,
  style: { fontFamily: "Body", fontWeight: "bold" },
});
```

`BitmapFontVariant` is `{ fontWeight?, fontStyle?, style? }`; the optional per-variant `style` layers extra `TextStyle` props onto that atlas only. `fontWeight` is matched on the bold axis (`"bold"`/`"bolder"` or numeric `>= 600`), `fontStyle` on the slant axis (`"italic"`/`"oblique"`); a request with no matching variant falls back to the base atlas.

All variants are **baseline-aligned** to the base atlas at bake time: each variant's `baseLineOffset` and `lineHeight` are normalized to the base font's, so a bold span and regular text sit on one shared baseline with no vertical drift (synthetic faux-bold/italic otherwise measure to a different baseline even from one source font).

**Declarative bitmap bake — `webFont({ bitmap })`.** A `webFont` can bake a bitmap atlas from the same loaded face during the scene's `preload`, so one declared font is usable as both canvas `Text` and `BitmapText` under a single family — no separate `installBitmapFont` call, no second name. The canvas face and the baked atlas live in separate Pixi registries, so there's no collision.

```ts
import { Scene } from "@yagejs/core";
import { webFont, TextComponent } from "@yagejs/renderer";

class HudScene extends Scene {
  readonly name = "hud";
  readonly preload = [
    // `bitmap: true` bakes with defaults; pass an object to tune it.
    webFont("fonts/Inter.woff2", {
      family: "Inter",
      bitmap: { size: 24, variants: [{ fontWeight: "bold" }] },
    }),
  ];
}

// Same family, both paths:
new TextComponent({ text: "menu", style: { fontFamily: "Inter" } }); // canvas Text
new TextComponent({
  text: "SCORE",
  bitmap: true,
  style: { fontFamily: "Inter" },
}); // bitmap atlas
```

`WebFontBakeOptions` is `{ size?, chars?, resolution?, padding?, style?, variants? }` — the same options as `installBitmapFont` minus `name`/`family` (the atlas always registers under the web font's `family`). `bitmap` **requires** `family`; without it the bake is skipped with a warning (the atlas needs a stable name to register under and uninstall on scene teardown). When the web font is unloaded, its canvas face is dropped and its hold on the baked atlas is released — the atlas (base + variants) is `BitmapFont.uninstall`ed only once every owner sharing the family (another web-font load, or an `installBitmapFont`) has released it. Two scenes preloading the same `webFont` are reference-counted by the `AssetManager`, so the atlas survives until the last scene unloads it.
