Spine 骨骼动画接入教程

修改于04/25195 浏览开发心得
本教程介绍如何在TapTap 制造中使用 Spine 骨骼动画。方案基于 spine-lua 3.8 运行时 + NanoVG 直绘,无外部依赖,轻量易用。
适合场景:2D 角色动画、UI 立绘、过场动画等。
horizontal linehorizontal line

一、获取文件

本方案需要两部分文件,全部放入 scripts/ 目录。

1.1 spine-lua 运行时(开源)

从 Spine 官方 GitHub 仓库获取 3.8 分支的 Lua 运行时:
路径:spine-lua/ 目录
具体操作:
```bash
# 克隆 3.8 分支(只需要 spine-lua 目录)
git clone --branch 3.8 --single-branch --depth 1 \
# 将 spine-lua 目录复制到你的项目
cp -r spine-runtimes/spine-lua   your-project/scripts/spine-lua
```
同时需要一个入口文件 spine.lua,用来聚合所有子模块。在 scripts/ 下创建 spine.lua:
```lua
-- spine.lua —— 入口模块,聚合 spine-lua 所有子模块
local spine = {}
spine.utils              = require "spine-lua.utils"
spine.SkeletonJson       = require "spine-lua.SkeletonJson"
spine.SkeletonData       = require "spine-lua.SkeletonData"
spine.BoneData           = require "spine-lua.BoneData"
spine.SlotData           = require "spine-lua.SlotData"
spine.Skin               = require "spine-lua.Skin"
spine.Attachment          = require "spine-lua.attachments.Attachment"
spine.RegionAttachment    = require "spine-lua.attachments.RegionAttachment"
spine.MeshAttachment      = require "spine-lua.attachments.MeshAttachment"
spine.Skeleton           = require "spine-lua.Skeleton"
spine.Bone               = require "spine-lua.Bone"
spine.Slot               = require "spine-lua.Slot"
spine.AnimationState     = require "spine-lua.AnimationState"
spine.AnimationStateData = require "spine-lua.AnimationStateData"
spine.Animation          = require "spine-lua.Animation"
spine.TextureAtlas       = require "spine-lua.TextureAtlas"
spine.TextureAtlasRegion = require "spine-lua.TextureAtlasRegion"
spine.AtlasAttachmentLoader = require "spine-lua.AtlasAttachmentLoader"
return spine
```

1.2 SpineNanoVG.lua(渲染后端)

这是连接 spine-lua 和 UrhoX NanoVG 的渲染后端,需要自己编写。核心职责:
- 加载 atlas 图片为 NanoVG image
- 遍历骨架的 slot/attachment,将 Region 和 Mesh 附件绘制为 NanoVG 图元
在 scripts/ 下创建 SpineNanoVG.lua,基本结构如下:
```lua
-- SpineNanoVG.lua —— Spine NanoVG 渲染后端
local spine = require "spine"
local SpineNVG = {}
local imageCache = {}  -- atlas 图片缓存
--- 加载 Spine 数据,返回实例
function SpineNVG.load(vg, atlasPath, jsonPath, scale)
    scale = scale or 1.0
    -- 1. 加载 atlas(需要提供纹理加载回调)
    local atlasData = cache:GetResource("TextFile", atlasPath):GetText()
    local atlas = spine.TextureAtlas.new(atlasData, function(texturePath)
        -- 加载 atlas 引用的图片到 NanoVG
        local imgHandle = nvgCreateImage(vg, texturePath, 0)
        imageCache[texturePath] = imgHandle
        return { handle = imgHandle, path = texturePath }
    end)
    -- 2. 加载骨骼 JSON
    local attachmentLoader = spine.AtlasAttachmentLoader.new(atlas)
    local skeletonJson = spine.SkeletonJson.new(attachmentLoader)
    skeletonJson.scale = scale
    local skeletonData = skeletonJson:readSkeletonDataFile(jsonPath)
    -- 3. 创建实例
    local skeleton = spine.Skeleton.new(skeletonData)
    skeleton:setToSetupPose()
    local stateData = spine.AnimationStateData.new(skeletonData)
    local state = spine.AnimationState.new(stateData)
    return {
        skeleton = skeleton,
        state = state,
        skeletonData = skeletonData,
    }
end
--- 绘制单个 Spine 实例
function SpineNVG.draw(vg, inst, x, y, scale, wireframe)
    scale = scale or 1.0
    local skeleton = inst.skeleton
    -- 遍历 skeleton.drawOrder,
    -- 对每个 slot 的 attachment 调用 NanoVG 绘制
    -- Region 类型 → nvgSave/nvgTranslate/nvgRotate + nvgImagePattern + nvgFill
    -- Mesh 类型  → 三角形逐个绘制
    -- (具体实现根据项目需求补充)
end
--- 批量绘制
function SpineNVG.drawMulti(vg, inst, positions, defaultScale)
    for _, pos in ipairs(positions) do
        SpineNVG.draw(vg, inst, pos.x, pos.y, pos.scale or defaultScale)
    end
end
--- 清理缓存
function SpineNVG.clearCache(vg)
    for path, handle in pairs(imageCache) do
        nvgDeleteImage(vg, handle)
    end
    imageCache = {}
end
return SpineNVG
```
注意:以上 draw 函数是框架示意,Region/Mesh 附件的具体渲染逻辑需要根据 spine-lua 的 attachment 数据结构完善。可参考 spine-runtimes 仓库中其他语言后端(如 spine-love)的实现方式。

1.3 最终文件结构

完成上述步骤后,scripts/ 目录结构如下:
```
scripts/
├── spine.lua                   # 入口模块(聚合 spine-lua 所有子模块)
├── spine-lua/                  # spine-lua 3.8 运行时(来自 GitHub)
│   ├── Animation.lua
│   ├── AnimationState.lua
│   ├── Skeleton.lua
│   ├── SkeletonJson.lua
│   ├── TextureAtlas.lua
│   ├── attachments/            # 附件类型
│   └── vertexeffects/          # 顶点特效(Jitter/Swirl)
└── SpineNanoVG.lua             # 渲染后端:NanoVG 直绘(自行编写)
```
不需要任何额外依赖,只要有 NanoVG 上下文即可。
horizontal linehorizontal line

二、快速上手

整个流程只有三步:加载 → 更新 → 绘制
```lua
local spine    = require "spine"
local SpineNVG = require "SpineNanoVG"
local vg   -- NanoVG 上下文
local inst -- Spine 实例
function Start()
    vg = nvgCreate(0)
-- 第一步:加载
inst = SpineNVG.load(vg, "spine/hero/hero.atlas", "spine/hero/hero.json", 1.0)
inst.state:setAnimationByName(0, "idle", true)
SubscribeToEvent("Update", "HandleUpdate")
SubscribeToEvent("NanoVGRender", "HandleNanoVGRender")
end
function HandleUpdate(eventType, eventData)
    local dt = eventData["TimeStep"]:GetFloat()
-- 第二步:每帧更新动画状态
inst.state:update(dt)
inst.state:apply(inst.skeleton)
inst.skeleton:updateWorldTransform()
end
function HandleNanoVGRender(eventType, eventData)
    local w = graphics:GetWidth() / graphics:GetDPR()
    local h = graphics:GetHeight() / graphics:GetDPR()
    nvgBeginFrame(vg, w, h, 1.0)
-- 第三步:绘制
SpineNVG.draw(vg, inst, w / 2, h - 100, 0.5)
nvgEndFrame(vg)
end
```
horizontal linehorizontal line

三、API 说明

3.1 加载:SpineNVG.load

lua
local inst = SpineNVG.load(vg, atlasPath, jsonPath, scale)
| 参数 | 类型 | 说明 |
|------|------|------|
| vg | userdata | NanoVG 上下文 |
| atlasPath | string | .atlas
文件路径(相对资源根) |
| jsonPath | string | .json
骨骼文件路径 |
| scale | number | 骨架加载缩放,通常传 1.0
|
返回一个实例 table,包含三个关键字段:
| 字段 | 类型 | 用途 |
|------|------|------|
| inst.skeleton | Skeleton | 骨架对象 |
| inst.state | AnimationState | 动画状态机,用于播放/切换动画 |
| inst.skeletonData | SkeletonData | 骨架数据,含动画列表等元信息 |

3.2 每帧更新

更新顺序固定,不能乱:
lua
inst.state:update(dt)                    -- 1. 推进动画时间线
inst.state:apply(inst.skeleton)          -- 2. 将动画应用到骨架
inst.skeleton:updateWorldTransform()     -- 3. 计算世界变换矩阵
这三行缺一不可,顺序也不能变。

3.3 绘制:SpineNVG.draw

lua
SpineNVG.draw(vg, inst, screenX, screenY, drawScale, debugWireframe)
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| screenX | number | — | 骨骼原点 X(角色脚底水平位置) |
| screenY | number | — | 骨骼原点 Y(角色脚底垂直位置) |
| drawScale | number | 1.0 | 绘制缩放 |
| debugWireframe | boolean | false | 是否叠加三角线框(调试用) |
注意 screenX/screenY
对应的是角色的脚底位置,不是中心点。

3.4 批量绘制:SpineNVG.drawMulti

如果需要在不同位置绘制同一个骨架的多个副本(比如一排小兵):
lua
SpineNVG.drawMulti(vg, inst, positions, defaultScale)
共享同一份骨架数据,减少内存占用。

3.5 清理缓存:SpineNVG.clearCache

lua
SpineNVG.clearCache(vg)
释放 NanoVG 内部缓存的图片资源,在切换场景或不再需要 Spine 动画时调用。
horizontal linehorizontal line

四、自适应缩放

实际项目中,角色大小需要适配屏幕。推荐根据骨架原始尺寸计算缩放比:
```lua
-- 从 JSON 的 skeleton 字段获取原始尺寸
local skelW = 711.64  -- skeleton.width
local skelH = 579.66  -- skeleton.height
-- 根据可用区域计算适配缩放
local availW = 300
local availH = 400
local fitScale = math.min(availW / skelW, availH / skelH)
SpineNVG.draw(vg, inst, centerX, bottomY, fitScale)
```
这样无论骨架原始尺寸多大,都能正确适配到目标区域内。
horizontal linehorizontal line

五、切换动画

通过 inst.state
控制动画播放:
```lua
-- 播放动画(第一个参数是轨道索引,0 为主轨道)
inst.state:setAnimationByName(0, "walk", true)   -- true = 循环
inst.state:setAnimationByName(0, "attack", false) -- false = 播放一次
-- 队列追加(当前动画播完后自动切换)
inst.state:addAnimationByName(0, "idle", true, 0) -- 最后一个参数是延迟时间
```
horizontal linehorizontal line

六、注意事项

1. 绘制必须在 NanoVGRender 事件中 — 这是引擎的硬性要求,不要在 Update 或 PostUpdate 里调用 NanoVG 绘制函数。
2. nvgCreateFont 只调用一次 — 如果你的项目同时用 NanoVG 画文字,字体创建放在 `Start()` 里,不要每帧调用。
3. 更新顺序不能乱 — state:update → state:apply → skeleton:updateWorldTransform,三步固定顺序。
4. 素材路径是相对资源根 — 放在 assets/ 目录下的资源,引用时不加 assets/ 前缀。例如文件在 assets/spine/hero/hero.atlas,代码里写 "spine/hero/hero.atlas"。
5. 这是纯 CPU 方案 — 适合中低复杂度的骨骼动画。如果角色特别复杂(大量网格变形),注意性能开销。
horizontal linehorizontal line

七、完整 API 速查

| API | 说明 |
|-----|------|
| SpineNVG.load(vg, atlas, json, scale)
| 加载 Spine 数据,返回实例 |
| SpineNVG.draw(vg, inst, x, y, scale, wireframe)
| 单实例绘制 |
| SpineNVG.drawMulti(vg, inst, positions, scale)
| 批量共享骨架绘制 |
| SpineNVG.clearCache(vg)
| 清理 NanoVG 图片缓存 |
| inst.state:setAnimationByName(track, name, loop)
| 设置动画 |
| inst.state:addAnimationByName(track, name, loop, delay)
| 队列追加动画 |
| inst.state:update(dt)
| 推进动画时间 |
| inst.state:apply(skeleton)
| 应用动画到骨架 |
| inst.skeleton:updateWorldTransform()
| 计算世界变换 |
有问题欢迎大佬们在评论区讨论。
7
10
3