Skip to content

Spine Animation

Estella has built-in Spine skeletal animation: bones, meshes, IK, skins, and events. It’s driven by the SpineAnimation component and controlled at runtime through the Spine resource (from the esengine/spine subpath).

Add it in the editor and point it at your Spine assets. The component lives in the main esengine package.

Property Type Default Description
skeletonPath asset '' Skeleton file (.json / .skel).
atlasPath asset '' Atlas file.
skin string '' Active skin (empty = default).
animation string '' Initial animation on track 0.
loop boolean true Loop the initial animation.
playing boolean true Whether playback advances.
timeScale number 1 Per-entity playback speed.
skeletonScale number 1 Uniform skeleton scale.
flipX / flipY boolean false Mirror horizontally / vertically.
color Color {1,1,1,1} Tint (RGBA, 0..1).
layer number 0 Sorting layer for draw order.
material asset none Optional custom material.
enabled boolean true Disable to freeze and hide (the same switch Spine.setEnabled throws; the Outliner eye rides it too).

Skeleton and atlas are asset slots in the Details panel: pick from the popover, drag a file in from the Content Browser, or use the slot’s locate / clear actions. The scene serializes them as portable @uuid: references, so moving or renaming the files never breaks the link. The editor previews the skeleton live in the viewport — including across Play/Stop — and once it loads, the animation and skin fields become dropdowns of that skeleton’s actual animations and skins.

Spine .atlas files work as exported — including multi-page atlases (each page texture loads alongside the atlas) and premultiplied alpha: a pma: true atlas header is honored automatically, no import setting needed. When you export with Compress textures enabled, atlas pages cook to KTX2 and transcode on device like any other texture.

The Spine resource controls tracks, mixing (crossfade), and per-track blending:

import { defineSystem, Query, Res, SpineAnimation } from 'esengine';
import { Spine } from 'esengine/spine';
const control = defineSystem([Query(SpineAnimation), Res(Spine)], (q, spine) => {
for (const [entity] of q) {
spine.setAnimation(entity, 'run', true); // play on track 0, looped
spine.setDefaultMix(entity, 0.15); // default crossfade
spine.setMixDuration(entity, 'idle', 'run', 0.25); // specific transition
spine.setTrackAlpha(entity, 0, 1.0); // blend a track in/out
}
});
Method Description
setAnimation(entity, name, loop) Play an animation on track 0.
setDefaultMix(entity, seconds) Default crossfade between any two animations.
setMixDuration(entity, from, to, seconds) Crossfade for a specific transition.
setTrackAlpha(entity, track, alpha) Blend a track’s contribution (0..1).
setEntityProps(entity, props) Set { skeletonScale?, flipX?, flipY?, layer? } at once.
setIKTarget(entity, constraint, x, y, mix) Aim an IK constraint at a world point (mix 0..1).

Transform / path constraint mixes are adjustable too — listConstraints(entity), getTransformConstraintMix / setTransformConstraintMix, and the path equivalents.

spine.setSkin(entity, 'armored');
spine.setAttachment(entity, 'weapon-slot', 'sword'); // swap a slot's attachment
spine.setSlotColor(entity, 'body', 1, 0.5, 0.5, 1); // r, g, b, a (0..1)
Method Description
setSkin(entity, name) Switch the active skin.
setAttachment(entity, slot, attachment) Swap a slot’s attachment (equip/variant).
setSlotColor(entity, slot, r, g, b, a) Tint an individual slot (four 0..1 channels).
const anims = spine.getAnimations(entity); // string[]
const skins = spine.getSkins(entity); // string[]
const bounds = spine.getBounds(entity); // { x, y, width, height } | null

The SpineEvents resource publishes track events once per frame — read it the same frame:

import { defineSystem, Res } from 'esengine';
import { SpineEvents } from 'esengine/spine';
const onSpine = defineSystem([Res(SpineEvents)], (spineEvents) => {
for (const e of spineEvents.events) {
// e.type: 'start' | 'interrupt' | 'end' | 'complete' | 'event'
// e.entity, e.track, e.animationName; for 'event': e.eventName + values
}
});
Event type Fired when
start An animation starts on a track.
interrupt An animation is interrupted by another.
end An animation is removed from a track.
complete An animation loop/playthrough completes.
event A user-authored event keyframe fires (eventName + values).

Posing a skeleton is two jobs. Advancing the animation is cheap and never skipped — skip it and the animation drifts, events stop firing, and the pose is wrong the moment the character is seen again. Resolving world transforms is the expensive half, and Estella skips that for entities no camera can draw.

It only skips when it can prove skipping is safe, which takes two facts about the asset:

  • A culling contract — a rectangle in the skeleton’s own space that you promise no pose ever leaves. You record it; nothing records it for you.
  • Stateless world constraints — the loaded runtime’s own answer about whether its world pose carries state between frames. Not something you configure.

Missing either, the world pose is resolved every frame, on screen or off.

Select the skeleton asset and use the Fixed Culling Bounds row of the Details panel.

Scan Animations plays every animation over its full duration in every skin and reports what it saw as Observed Bounds. That is a measurement, not a promise: a blend of two animations is not the union of their extents, game code can move a bone the export never did, and an extreme between two samples is simply not sampled. Use as Fixed Culling Bounds copies it into the contract — padding it first is normal, and accepting it is your promise, not the scan’s.

Remove Fixed Bounds writes a rectangle of zero area, which is what “no contract” is here: a freshly imported asset carries one, and a deleted key would come back at the next import.

1000 skeletons, measuring pose + extract + submit:

Visible Posed every frame With contracts
1000 / 1000 3.39 ms 3.36 ms
200 / 1000 3.29 ms 1.06 ms
0 / 1000 3.26 ms 0.49 ms

The first row is the important one: nothing was traded away to get the other two. What is skipped is work nobody was going to consume.

Spine can report what a frame did and why each part of it was paid — no profiler needed. Counting is off by default, so a runtime nobody is watching pays a branch per entity and nothing else.

import { Res } from 'esengine';
import { Spine, formatSpineDiagnostics } from 'esengine/spine';
// In a system taking Res(Spine) — once, then after any later frame:
spine.observe(true);
console.log(formatSpineDiagnostics(spine.diagnostics()));
spine — frame 412, 1000 entities across 3 assets
time pose 2.31ms readback 0.98ms total 3.29ms
world 400 resolved, 600 unresolved, 150 already current
logical 1000 advances
draw 350 extracted, 650 camera declines
geometry 350 batches, 112000 vertices, 168000 indices
bytes 3.4MB out of the modules, 3.4MB into the core
crossings 1000 pose, 400 world, 350 batch data, 350 submit
3.8 120 frames — total p50 3.28ms p95 4.01ms max 5.66ms
hero.skel#gen1 800 3.8 may defer
boss.skel#gen1 150 3.8 always resolves — no-certificate
rope.skel#gen1 50 4.2 always resolves — stateful-constraints
* 150 entities across 1 asset (boss.skel#gen1) resolve a world pose every
frame because nothing certified their extent. Scan the asset and record a
culling contract to let them skip it while no camera wants them.
* 50 entities across 1 asset (rope.skel#gen1) can never defer: their world
pose carries state across frames, which no culling contract changes.
* 600 world poses went unresolved — work this frame did not do.

Reading it:

  • world … unresolved is what the frame skipped. Zero here with entities off screen means nothing was allowed to skip — look at the asset rows for why.
  • always resolves — no-certificate is the actionable one: that asset has no culling contract, so it pays a world pose every frame wherever it stands.
  • always resolves — stateful-constraints is not actionable. No contract changes it, which is why the findings list those assets separately instead of sending you to certify something that would not help.
  • p50 / p95 / max are the frames behind this one. A single frame is a sample; these say whether it was a typical one.

diagnostics() returns the same report as data (SpineSceneDiagnostics) for your own panel or overlay — formatSpineDiagnostics is just one renderer of it.

  • Set a default mix (setDefaultMix) so transitions crossfade instead of snapping; override hot paths with setMixDuration.
  • Layer with tracks + setTrackAlpha (e.g. an aim/overlay on track 1 blended over a locomotion track 0).
  • Share skeleton + atlas across entities — they ref-count a single loaded skeleton.
  • Drive whole characters from the Animator state machine, which can target Spine as well as sprites.
  • Record a culling contract on every skeleton that appears in numbers — it is the difference between paying for the characters on screen and paying for all of them.