跳转到内容

Spine 动画

Estella 内置 Spine 骨骼动画:骨骼、网格、IK、皮肤与事件。 它由 SpineAnimation 组件驱动,并在运行时通过 Spine 资源(来自 esengine/spine 子路径) 控制。

在编辑器里添加并指向你的 Spine 资源。组件本身在主 esengine 包里。

属性 类型 默认 说明
skeletonPath asset '' 骨骼文件(.json / .skel)。
atlasPath asset '' 图集文件。
skin string '' 活动皮肤(空 = 默认)。
animation string '' 轨道 0 上的初始动画。
loop boolean true 循环初始动画。
playing boolean true 播放是否推进。
timeScale number 1 逐实体的播放速度。
skeletonScale number 1 骨骼统一缩放。
flipX / flipY boolean false 水平 / 垂直镜像。
color Color {1,1,1,1} 着色(RGBA,0..1)。
layer number 0 决定绘制顺序的排序层。
material asset 无 可选的自定义材质。
enabled boolean true 关掉即冻结并隐藏(与 Spine.setEnabled 是同一个开关;世界大纲的眼睛也走这条通道)。

骨骼与图集是 Details 面板里的资产槽位:从弹出选择器里挑,从内容浏览器拖进来, 或用槽位的定位 / 清除操作。场景把它们序列化为可移植的 @uuid: 引用,移动或重命名 文件都不会断链。编辑器在视口里实时预览骨骼——跨越播放/停止也不丢——骨骼加载完成后, animation 和 skin 字段会变成该骨骼实际动画与皮肤的下拉列表。

Spine 导出的 .atlas 文件即用即好——包括多页图集(每页纹理随图集一起加载)和 预乘 alpha:图集头里的 pma: true 自动生效,无需导入设置。导出时开启压缩纹理, 图集页会烘焙成 KTX2,并像其他纹理一样在设备上转码。

Spine 资源控制轨道、混合(crossfade)与逐轨道混合:

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
}
});
方法 说明
setAnimation(entity, name, loop) 在轨道 0 播放一个动画。
setDefaultMix(entity, seconds) 任意两个动画之间的默认交叉淡入。
setMixDuration(entity, from, to, seconds) 特定转移的交叉淡入。
setTrackAlpha(entity, track, alpha) 混合某轨道的贡献(0..1)。
setEntityProps(entity, props) 一次性设置 { skeletonScale?, flipX?, flipY?, layer? }。
setIKTarget(entity, constraint, x, y, mix) 让 IK 约束对准一个世界坐标点(mix 0..1)。

变换 / 路径约束的混合也可调——listConstraints(entity)、getTransformConstraintMix / setTransformConstraintMix,以及 path 对应项。

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)
方法 说明
setSkin(entity, name) 切换活动皮肤。
setAttachment(entity, slot, attachment) 替换某槽位的附件(换装/变体)。
setSlotColor(entity, slot, r, g, b, a) 给单个槽位着色(四个 0..1 通道)。
const anims = spine.getAnimations(entity); // string[]
const skins = spine.getSkins(entity); // string[]
const bounds = spine.getBounds(entity); // { x, y, width, height } | null

SpineEvents 资源每帧发布一次轨道事件——在同一帧读取:

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
}
});
事件 type 触发时机
start 某轨道上开始一个动画。
interrupt 一个动画被另一个打断。
end 一个动画从轨道移除。
complete 一个动画循环/播放完成。
event 用户编辑的事件关键帧触发(eventName + 值)。

摆一个骨骼是两件事。推进动画很便宜,而且从不跳过——跳过它动画就会漂移、事件停止触发, 角色再次被看见的那一刻姿势是错的。解算世界变换才是贵的那一半,Estella 会为任何相机都 画不到的实体跳过它。

只有在能证明「跳过是安全的」时才跳,这需要关于该资产的两个事实:

  • 一份剔除契约——骨骼自身空间里的一个矩形,你承诺任何姿势都不会越出它。由你记录, 没有任何东西会替你记录。
  • 无状态的世界约束——已加载运行时对「它的世界姿势是否跨帧携带状态」的自述。不可配置。

缺任何一个,世界姿势就每帧都解算,不论在不在屏幕上。

选中骨骼资产,使用 Details 面板的 固定剔除范围 一行。

扫描动画 会在每个皮肤下把每个动画完整播一遍,把看到的报告为 扫描所见范围。那是一次 测量,不是承诺:两个动画的混合并不等于它们范围的并集,游戏代码能把骨头移到导出从未去过的 地方,而两个采样点之间的极值根本没被采到。用作固定剔除范围 把它写进契约——先留一点余量 是常态,而接受它是你的承诺,不是扫描的。

移除固定范围 写入的是一个零面积矩形,那就是这里的「无契约」:刚导入的资产本来就带着一个, 而被删掉的键会在下次导入时回来。

1000 个骨骼,测的是 pose + extract + submit:

可见 每帧都摆 有契约
1000 / 1000 3.39 ms 3.36 ms
200 / 1000 3.29 ms 1.06 ms
0 / 1000 3.26 ms 0.49 ms

第一行才是关键:并没有为了后两行牺牲掉什么。被跳过的是没有任何人会消费的工作。

Spine 能报告一帧到底做了什么、每一部分为什么被付掉——不需要 profiler。计数默认关闭, 所以没人在看的运行时每个实体只多一个分支,别的什么都不付。

import { Res } from 'esengine';
import { Spine, formatSpineDiagnostics } from 'esengine/spine';
// 在一个取 Res(Spine) 的系统里——先开一次,之后任何一帧都可以读:
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.

怎么读:

  • world … unresolved 是这一帧省掉的。明明有实体在屏幕外它却是 0,说明没有任何东西 被允许跳过——原因在下面的资产行里。
  • always resolves — no-certificate 是可行动的那一条:该资产没有剔除契约,所以不管它 站在哪里,每帧都要付一次世界姿势。
  • always resolves — stateful-constraints 不可行动。没有契约能改变它——findings 把这类 资产单独列出,正是为了不把你派去做一件不会有帮助的事。
  • p50 / p95 / max 是这一帧背后的那些帧。单帧只是一次采样,这几个数才说得出它是否典型。

diagnostics() 也以数据形式(SpineSceneDiagnostics)返回同一份报告,供你自己的面板或 overlay 使用——formatSpineDiagnostics 只是它的一种渲染。

  • 设一个默认混合(setDefaultMix)让转移交叉淡入而非硬切;热点路径用 setMixDuration 覆盖。
  • 用轨道 + setTrackAlpha 分层(例如在轨道 1 放一个瞄准/叠加,混合在轨道 0 的移动之上)。
  • 跨实体共享骨骼 + 图集——它们引用计数同一份已加载骨骼。
  • 用 Animator 状态机驱动整个角色,它既能驱动 Spine 也能驱动精灵。
  • 给每个会成规模出现的骨骼记录剔除契约——它决定了你付的是「屏幕上那些角色」还是「全部角色」。
  • 动画 —— Animator 状态机可以驱动 Spine。
  • 资源 —— 加载骨骼 + 图集资源。
  • DragonBones 动画 —— 另一个骨骼运行时,MIT 许可。