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).
The SpineAnimation component
Section titled “The SpineAnimation component”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.
Atlases
Section titled “Atlases”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.
Play & blend animations
Section titled “Play & blend animations”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.
Skins & attachments
Section titled “Skins & attachments”spine.setSkin(entity, 'armored');spine.setAttachment(entity, 'weapon-slot', 'sword'); // swap a slot's attachmentspine.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). |
Query a skeleton
Section titled “Query a skeleton”const anims = spine.getAnimations(entity); // string[]const skins = spine.getSkins(entity); // string[]const bounds = spine.getBounds(entity); // { x, y, width, height } | nullAnimation events
Section titled “Animation events”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). |
Culling contracts & deferred world poses
Section titled “Culling contracts & deferred world poses”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.
Recording a contract
Section titled “Recording a contract”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.
What it buys
Section titled “What it buys”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.
Why is Spine expensive here?
Section titled “Why is Spine expensive here?”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 … unresolvedis 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-certificateis the actionable one: that asset has no culling contract, so it pays a world pose every frame wherever it stands.always resolves — stateful-constraintsis 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 / maxare 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.
Best practices
Section titled “Best practices”- Set a default mix (
setDefaultMix) so transitions crossfade instead of snapping; override hot paths withsetMixDuration. - 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.
See also
Section titled “See also”- Animation — the Animator state machine can drive Spine.
- Assets — loading skeleton + atlas assets.
- DragonBones Animation — the other skeletal runtime, MIT licensed.