All examples

Trimmed sprite atlas

2D2D
  • Mouse
  • Touch

A sprite packer trims each frame to its visible pixels and records where it sat in the original cell. An .atlas.json frame keeps that as sourceOffset beside sourceSize, and the TexturePacker and Aseprite importers write it. Three runners play one clip: from the untrimmed sheet, from the trimmed sheet, and from the trimmed sheet without sourceOffset, which stretches each frame over its cell. A slider scrubs the clip by time.

Off airLivePaused

Trimmed sprite atlas

2D · 2D

Three copies of the same pixel-art runner standing on a strip of grass and earth against a dusk-blue sky; the two on the left are identical and the one on the right is stretched a little wider.
Open standalone

WebGPU: checking…

Try this

  1. Set Overlay to sourceOffset: the tinted runner covers the untrimmed one exactly on every frame.
  2. Set Overlay to No offset and step through the run: the outlines part, wider on some frames than others.
  3. Turn Playing off and drag Time past the end of the clip: a looping clip wraps back to its first frame.
Read the guide
Show source code
main.ts
// `ignifx` does not re-export the time-to-frame rule (0.16.0), so it comes from `@ignifx/2d`.import { spriteClipFrameAtTime } from "@ignifx/2d";import { Camera2D, SpriteAnimator, SpriteRenderer, twoD, Vec2 } from "ignifx";import { bootExample } from "../_kit/boot.ts";import { readout, select, slider, toggle } from "../_kit/panel.ts";import type { App, AssetHandle, Entity, SpriteAnimationAsset, SpriteAtlasAsset } from "ignifx";/** * A trimmed sprite atlas: frames packed without their transparent margins, drawn exactly where the * untrimmed frames would be. * * A sprite packer trims every frame to its visible pixels and records where the trimmed rectangle * sat in the original cell — TexturePacker's and Aseprite's `spriteSourceSize`. Since 0.16.0 an * `.atlas.json` frame carries that as `sourceOffset`, beside the untrimmed `sourceSize`, and the * TexturePacker and Aseprite importers write it. The frame then draws at its trimmed size, offset to * where the untrimmed frame would have put it, with its pivot in `[0, 1]` of the untrimmed cell. * `tools/build-trimmed-atlas.ts` trimmed the runner sheet and ran it through * `importTexturePackerAtlas`; the sheet went from 144×36 to 64×52 pixels. * * Three runners play the same clip from the same `.spriteanim.json`: on the left from the untrimmed * sheet, in the middle from the trimmed one, on the right from the trimmed one with `sourceOffset` * removed — the document the importer wrote before 0.16.0, whose frames stretch their trimmed pixels * over the whole cell. "Overlay" lays one of the trimmed runners over the left one, tinted: with * `sourceOffset` the two outlines never part. * * The Time slider scrubs the clip. `SpriteAnimator.sampleTime(clip, seconds)` shows the frame that * plays that far in, without playing; `spriteClipFrameAtTime` is the same rule as a pure function, * which the readout uses. Past the end, a looping clip wraps. *//** Pixels per world metre: one 16-pixel cell is one metre. */const PIXELS_PER_UNIT = 16;/** The clips the slider can scrub, in the order the select lists them. */const CLIPS = ["run", "idle"] as const;/** How far the Time slider reaches, in seconds: past the end of either clip, to show the wrap. */const MAX_TIME = 1.2;/** The three runners: label, atlas address and where each stands, in metres. Drawn at three times size. */const RUNNERS = [  { label: "Untrimmed", atlas: "2d/runner.atlas.json", x: 0.5 },  { label: "sourceOffset", atlas: "2d/runner-trimmed.atlas.json", x: 4 },  { label: "No offset", atlas: "2d/runner-trimmed-no-offset.atlas.json", x: 7.5 },] as const;/** How much larger than one metre the runners are drawn. */const SCALE = 3;/** The overlay choices: none, or which trimmed runner to lay over the untrimmed one. */const OVERLAYS = ["Off", "sourceOffset", "No offset"] as const;/** The tint an overlaid runner takes, so its outline shows against the one beneath. */const OVERLAY_TINT = { r: 1, g: 0.35, b: 0.3, a: 0.65 } as const;/** The design resolution the pixel-perfect camera fits a whole-number zoom to. */const REFERENCE_RESOLUTION = { x: 240, y: 135 } as const;/** * Where the camera looks, in metres: right of the runners' centre, so the panel, open on the right * of the frame, covers empty ground rather than the third runner. */const SHOT = { x: 5.6, y: 1.8 } as const;/** The ground strip: the first and last tile's X, and how many rows deep, in metres. */const GROUND = { from: -4, to: 14, rows: 5 } as const;/** The dusk sky, `#2B2F45`, as in `sprite-animation`. */const CLEAR_COLOR = { r: 0.169, g: 0.184, b: 0.271, a: 1 };/** One runner on stage. */interface Runner {  readonly entity: Entity;  readonly renderer: SpriteRenderer;  readonly animator: SpriteAnimator;  readonly atlas: AssetHandle<SpriteAtlasAsset>;  /** Where it stands when not overlaid. */  readonly home: number;}/** * Writes a trimmed frame's placement. * * @param runner - The runner whose current frame to describe. * @returns E.g. `"9×16 at 4,0 of 16×16"`, or `"untrimmed"`. */function describeTrim(runner: Runner | undefined): string {  const info = runner?.atlas.value.frame(runner.animator.frame);  const trim = info?.trim;  if (info === undefined || info === null) {    return "—";  }  if (trim === null || trim === undefined) {    return `${String(info.widthPx)}×${String(info.heightPx)}, untrimmed`;  }  return (    `${String(info.widthPx)}×${String(info.heightPx)} at ${String(trim.offsetXPx)},${String(trim.offsetYPx)}` +    ` of ${String(trim.sourceWidthPx)}×${String(trim.sourceHeightPx)}`  );}/** * Places a runner. * * @param app - The app. * @param atlas - Its atlas. * @param clips - The clips, shared by all three. * @param x - Where it stands. * @returns The runner. */function spawn(app: App, atlas: AssetHandle<SpriteAtlasAsset>, clips: AssetHandle<SpriteAnimationAsset>, x: number): Runner {  const entity = app.world.createEntity("Runner");  entity.transform.position2D = new Vec2(x, 0);  entity.transform.localScale2D = new Vec2(SCALE, SCALE);  const renderer = entity.addComponent(SpriteRenderer, { sprite: atlas });  const animator = entity.addComponent(SpriteAnimator, { animations: clips, defaultClip: "run", playOnAwake: true });  return { entity, renderer, animator, atlas, home: x };}bootExample({  title: "Trimmed sprite atlas",  extensions: [twoD({ pixelsPerUnit: PIXELS_PER_UNIT })],  settings: {    // Multisampling would soften exactly the edges pixel art exists to keep sharp.    rendering: { clearColor: CLEAR_COLOR, msaaSamples: 1 },    sortingLayers: { sortingLayers: ["Terrain", "Default"] },  },  async setup({ app, panel }) {    // Awaited before `app.start()`, so the first frame already has every sheet.    const [terrain, clips, ...atlases] = await Promise.all([      app.assets.loadAsync<SpriteAtlasAsset>("2d/terrain.atlas.json"),      app.assets.loadAsync<SpriteAnimationAsset>("2d/runner.spriteanim.json"),      ...RUNNERS.map((runner) => app.assets.loadAsync<SpriteAtlasAsset>(runner.atlas)),    ]);    // Grass over earth to stand on: tiles have a centre pivot, so the grass's top edge is at y = 0.    const grass = terrain.value.requireFrame("terrain_0");    const dirt = terrain.value.requireFrame("terrain_1");    for (let row = 0; row < GROUND.rows; row += 1) {      for (let x = GROUND.from; x <= GROUND.to; x += 1) {        const tile = app.world.createEntity("Ground");        tile.transform.position2D = new Vec2(x, -0.5 - row);        tile.addComponent(SpriteRenderer, { sprite: terrain, sortingLayer: "Terrain" }).frame = row === 0 ? grass : dirt;      }    }    const runners: Runner[] = [];    RUNNERS.forEach((spec, index) => {      const atlas = atlases[index];      if (atlas !== undefined) {        runners.push(spawn(app, atlas, clips, spec.x));      }    });    const [reference, placed] = runners;    const eye = app.world.createEntity("Main Camera");    eye.transform.position2D = new Vec2(SHOT.x, SHOT.y);    eye.addComponent(Camera2D, { pixelPerfect: true, referenceResolution: REFERENCE_RESOLUTION });    let clip: string = "run";    let scrubbed = 0;    /**     * Where the clip is: the scrubbed time, or, while it plays, how far into the current pass.     *     * @returns Seconds into the clip.     */    const clipTime = (): number => {      const animator = reference?.animator;      const duration = animator?.clip?.durationSeconds ?? 0;      if (animator === undefined || !animator.isPlaying || duration <= 0) {        return scrubbed;      }      return animator.time % duration;    };    /**     * Shows the frame `seconds` into the clip on every runner, without playing.     *     * @param seconds - How far into the clip.     */    const scrub = (seconds: number): void => {      scrubbed = seconds;      for (const runner of runners) {        runner.animator.sampleTime(clip, seconds);      }    };    /**     * Lays one trimmed runner over the untrimmed one, or sends both home.     *     * @param choice - One of {@link OVERLAYS}.     */    const overlay = (choice: string): void => {      runners.forEach((runner, index) => {        const isOverlaid = index > 0 && RUNNERS[index]?.label === choice;        runner.entity.transform.position2D = new Vec2(isOverlaid ? (reference?.home ?? 0) : runner.home, 0);        runner.renderer.color = isOverlaid ? OVERLAY_TINT : { r: 1, g: 1, b: 1, a: 1 };        // Drawn after the untrimmed runner, so the tint sits on top.        runner.renderer.orderInLayer = isOverlaid ? 1 : 0;      });    };    panel({      title: "Trimmed sprite atlas",      groups: [        {          label: "Clip",          controls: [            select("Clip", [...CLIPS], {              value: clip,              change: (next: string): void => {                clip = next;                for (const runner of runners) {                  runner.animator.play(next, { restart: true });                }              },            }),            toggle("Playing", {              value: true,              change: (on: boolean): void => {                for (const runner of runners) {                  if (on) {                    runner.animator.resume();                  } else {                    runner.animator.pause();                  }                }              },              read: (): boolean => reference?.animator.isPlaying ?? false,            }),            slider(              "Time",              { min: 0, max: MAX_TIME, step: 0.01, format: (value: number): string => `${value.toFixed(2)} s` },              { value: 0, change: scrub, read: clipTime },            ),            readout("Frame at time", (): string => {              const resolved = reference?.animator.clip;              if (resolved === null || resolved === undefined) {                return "—";              }              const timing = { frameCount: resolved.frames.length, fps: resolved.fps, loop: resolved.loop };              const frame = spriteClipFrameAtTime(timing, clipTime(), reference?.animator.speed ?? 1);              return `${String(frame + 1)} of ${String(resolved.frames.length)}`;            }),          ],        },        {          label: "Placement",          controls: [            select("Overlay", [...OVERLAYS], { value: "Off", change: overlay }),            readout("Trimmed frame", (): string => describeTrim(placed)),            // Printed by `tools/build-trimmed-atlas.ts`: 3,328 pixels against 5,184.            readout("Sheet", (): string => "64×52 vs 144×36"),          ],        },      ],    });  },});
tools/build-trimmed-atlas.ts
// Writes the trimmed runner sheet `sprite-atlas-trim` loads, and its two atlas documents, into// `examples/assets/2d/`. Run it from the repository root:////   node examples/sprite-atlas-trim/tools/build-trimmed-atlas.ts//// ## What it does//// It plays the part of a sprite packer such as TexturePacker, on the runner sheet the other 2D// examples already use (`2d/runner.png`, fourteen 16×16 frames)://// 1. **Trims** every frame to the smallest rectangle holding a visible pixel, and records where that//    rectangle sat inside the 16×16 cell — TexturePacker's `spriteSourceSize`.// 2. **Packs** the trimmed rectangles into a smaller sheet, `2d/runner-trimmed.png`, one pixel apart.// 3. **Writes the document a packer would**: TexturePacker's JSON hash format, with `trimmed`,//    `spriteSourceSize`, `sourceSize` and a bottom-centre `pivot` per frame. It is never written to//    disk; it goes straight into `@ignifx/2d`'s `importTexturePackerAtlas`, the documented way to//    bring a packer's sheet into ignifx, which writes `2d/runner-trimmed.atlas.json` with a//    `sourceSize` and a `sourceOffset` on every frame (0.16.0 and later).// 4. Writes `2d/runner-trimmed-no-offset.atlas.json`: the same document without the `sourceOffset`//    fields, which is what the importer wrote before 0.16.0. Such a frame draws its trimmed pixels//    stretched over the whole untrimmed cell. The example shows it beside the fixed one.//// Every pixel it writes is a pixel of `runner.png`, moved; nothing is resampled or recoloured. The// frames keep `runner.atlas.json`'s names and order, so `runner.spriteanim.json` drives all three.import { createHash } from "node:crypto";import { readFile, writeFile } from "node:fs/promises";import path from "node:path";import { defineSpriteAtlas, importTexturePackerAtlas } from "ignifx";import sharp from "sharp";import type { SpriteAtlasDefinition } from "ignifx";/** Where the sheets live. */const OUT = path.resolve(import.meta.dirname, "..", "..", "assets", "2d");/** The packed sheet's width, in pixels: wide enough for a few trimmed frames a row. */const SHEET_WIDTH = 64;/** Transparent pixels between packed frames, so linear sampling would never bleed. */const PADDING = 1;/** A rectangle in pixels, top-left origin. */interface Rect {  readonly x: number;  readonly y: number;  readonly w: number;  readonly h: number;}/** * Finds the smallest rectangle inside a cell that holds every pixel with any alpha. * * @param pixels - The sheet, RGBA8. * @param sheetWidth - The sheet's width, in pixels. * @param cell - The cell to search. * @returns The rectangle, relative to the cell's top-left corner. * @throws When the cell is fully transparent: the runner has no empty frame. */function opaqueBounds(pixels: Buffer, sheetWidth: number, cell: Rect): Rect {  let left = cell.w;  let top = cell.h;  let right = -1;  let bottom = -1;  for (let y = 0; y < cell.h; y += 1) {    for (let x = 0; x < cell.w; x += 1) {      const alpha = pixels[((cell.y + y) * sheetWidth + cell.x + x) * 4 + 3] ?? 0;      if (alpha > 0) {        left = Math.min(left, x);        top = Math.min(top, y);        right = Math.max(right, x);        bottom = Math.max(bottom, y);      }    }  }  if (right < 0) {    throw new Error(`the cell at ${String(cell.x)}, ${String(cell.y)} is empty.`);  }  return { x: left, y: top, w: right - left + 1, h: bottom - top + 1 };}/** * Writes a JSON document the way the committed 2D documents are written, and reports its digest. * * @param name - The file name under `assets/2d/`. * @param value - The document. */async function writeJson(name: string, value: unknown): Promise<void> {  const text = `${JSON.stringify(value, null, 2)}\n`;  await writeFile(path.join(OUT, name), text);  console.log(`${name}: ${String(Buffer.byteLength(text))} bytes, sha256 ${createHash("sha256").update(text).digest("hex")}`);}const source = JSON.parse(await readFile(path.join(OUT, "runner.atlas.json"), "utf8")) as SpriteAtlasDefinition;const { data: pixels, info } = await sharp(path.join(OUT, "runner.png"))  .ensureAlpha()  .raw()  .toBuffer({ resolveWithObject: true });// 1 and 2: trim each frame, then shelf-pack the trimmed rectangles left to right, top to bottom.const packed: { name: string; cell: Rect; trim: Rect; at: Rect }[] = [];let cursorX = PADDING;let cursorY = PADDING;let rowHeight = 0;for (const frame of source.frames) {  const cell = { x: frame.x, y: frame.y, w: frame.w, h: frame.h };  const trim = opaqueBounds(pixels, info.width, cell);  if (cursorX + trim.w + PADDING > SHEET_WIDTH) {    cursorX = PADDING;    cursorY += rowHeight + PADDING;    rowHeight = 0;  }  packed.push({ name: frame.name, cell, trim, at: { x: cursorX, y: cursorY, w: trim.w, h: trim.h } });  cursorX += trim.w + PADDING;  rowHeight = Math.max(rowHeight, trim.h);}const sheetHeight = cursorY + rowHeight + PADDING;const composites = await Promise.all(  packed.map(async ({ cell, trim, at }) => ({    input: await sharp(path.join(OUT, "runner.png"))      .extract({ left: cell.x + trim.x, top: cell.y + trim.y, width: trim.w, height: trim.h })      .png()      .toBuffer(),    left: at.x,    top: at.y,  })),);const png = await sharp({  create: { width: SHEET_WIDTH, height: sheetHeight, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },})  .composite(composites)  .png({ compressionLevel: 9, palette: false })  .toBuffer();await writeFile(path.join(OUT, "runner-trimmed.png"), png);console.log(  `runner-trimmed.png: ${String(SHEET_WIDTH)}x${String(sheetHeight)}, ${String(png.length)} bytes, ` +    `sha256 ${createHash("sha256").update(png).digest("hex")} (runner.png is ${String(info.width)}x${String(info.height)})`,);// 3: the document TexturePacker's "JSON (Hash)" exporter would write for this sheet, imported.const texturePacker = {  frames: Object.fromEntries(    packed.map(({ name, cell, trim, at }) => [      `${name}.png`,      {        frame: at,        rotated: false,        trimmed: true,        spriteSourceSize: { x: trim.x, y: trim.y, w: trim.w, h: trim.h },        sourceSize: { w: cell.w, h: cell.h },        pivot: { x: 0.5, y: 1 },      },    ]),  ),  meta: { image: "runner-trimmed.png", size: { w: SHEET_WIDTH, h: sheetHeight }, scale: "1" },};const trimmed = importTexturePackerAtlas(texturePacker, { sampling: "nearest" });await writeJson("runner-trimmed.atlas.json", trimmed);// 4: the same frames without `sourceOffset`, as the importer wrote them before 0.16.0.const unplaced = defineSpriteAtlas(  {    image: trimmed.image,    sampling: trimmed.sampling,    frames: trimmed.frames.map(({ sourceOffset: _dropped, ...frame }) => frame),  },  trimmed.image,);await writeJson("runner-trimmed-no-offset.atlas.json", unplaced);
Uses
SpriteAtlasAssetsourceOffsetimportTexturePackerAtlasSpriteAnimator.sampleTimespriteClipFrameAtTimeSpriteFrameInfo.trim
Assets
Runner sprite sheet and side-on terrain — Apache-2.0, Astrum Forge Studios