From 4cea23e505c27c8f8f3b30aa967a5e344d204f70 Mon Sep 17 00:00:00 2001 From: johanzhu Date: Wed, 24 Jul 2024 17:18:15 +0800 Subject: [PATCH 1/4] feat: add spine doc --- docs/zh/graphics/2D/spine.md | 503 ++++++++++++++++++++++------ examples/spine-animation.ts | 41 ++- examples/spine-change-attachment.ts | 76 ++--- examples/spine-follow-shoot.ts | 75 +++++ examples/spine-full-skin-change.ts | 64 ++++ examples/spine-hack-slot-texture.ts | 141 -------- examples/spine-mix-and-match.ts | 213 ++++++++++++ examples/spine-performance.ts | 22 +- examples/spine-physics.ts | 63 ++++ examples/spine-skin-change.ts | 66 ---- 10 files changed, 889 insertions(+), 375 deletions(-) create mode 100644 examples/spine-follow-shoot.ts create mode 100644 examples/spine-full-skin-change.ts delete mode 100644 examples/spine-hack-slot-texture.ts create mode 100644 examples/spine-mix-and-match.ts create mode 100644 examples/spine-physics.ts delete mode 100644 examples/spine-skin-change.ts diff --git a/docs/zh/graphics/2D/spine.md b/docs/zh/graphics/2D/spine.md index 7aa6238fe1..4d1adbd4d2 100644 --- a/docs/zh/graphics/2D/spine.md +++ b/docs/zh/graphics/2D/spine.md @@ -6,156 +6,471 @@ group: 2D label: Graphics/2D --- -Spine 动画是一款针对游戏开发的 `2D 骨骼动画`,它通过将图片绑定到骨骼上,然后再控制骨骼实现动画,它可以满足程序对动画的`控制`与`自由度`,同时也为美术与设计提供了更`高效`和`简洁`的工作流。 +Spine 动画是一款针对游戏开发的 2D 骨骼动画,它通过将图片绑定到骨骼上,然后再控制骨骼实现动画,它可以满足程序对动画的控制与自由度,同时也为美术与设计提供了更高效和简洁的工作流。 +相较于传统的帧动画,Spine 动画更具优势: -| | 表现效果 | 性能 | 文件体积 | 灵活程度 | 上手难度 | 是否免费 | -| ------ | -------- | ---- | -------- | -------- | -------- | -------- | -| Spine | 最优 | 次之 | 最优 | 最优 | 最复杂 | 工具收费 | -| Lottie | 次之 | 最差 | 次之 | 次之 | 次之 | 免费 | -| 帧动画 | 最差 | 最优 | 最差 | 最差 | 最简单 | 免费 | +- **更小的体积:** 传统的动画需要提供每一帧图片。而 Spine 动画只保存骨骼的动画数据,它所占用的空间非常小。 +- **美术需求:** Spine 动画需要的美术资源更少,能为您节省出更多的人力物力更好的投入到游戏开发中去。 +- **流畅性:** Spine 动画使用差值算法计算中间帧,这能让你的动画总是保持流畅的效果。 +- **装备附件:** 图片绑定在骨骼上来实现动画。如果你需要可以方便的更换角色的装备满足不同的需求。甚至改变角色的样貌来达到动画重用的效果。 +- **混合:** 动画之间可以进行混合。比如一个角色可以开枪射击,同时也可以走、跑、跳或者游泳。 +- **程序动画:** 可以通过代码控制骨骼,比如可以实现跟随鼠标的射击,注视敌人,或者上坡时的身体前倾等效果。 -Spine 动画支持换皮换肤,动画混合以及使用代码控制骨骼。 +# 在 Galacean 编辑器中使用 Spine 动画 +Galacean 编辑器内置了对 Spine 动画的支持,无需额外下载或配置,开箱即用,大大简化了开发流程。 +## 资产管理 +### 从 Spine 编辑器导出资产 +你可以在《Spine用户指南》中找到完整的步骤, 说明如何来: -## 在编辑器中使用 +1. [导出 skeleton 和 animation 数据](https://zh.esotericsoftware.com/spine-export) +2. [导出包含 skeleton 图像的 texture atlases](https://zh.esotericsoftware.com/spine-texture-packer) -在编辑器中使用 spine 包含下面几个步骤: +下面展示了 spine 导出资产的一个简要流程: -```mermaid -flowchart LR - 资源导出 --> 资源上传 --> 添加组件 --> 编写脚本 -``` +1. 完成动画制作后,单击 `Spine 菜单`>`导出` ,打开导出窗口 + + + +2. 选择导出窗口左上角的**二进制** ( 推荐使用二进制,以二进制格式而不是JSON格式导出,会使文件体积更小,加载更快 + + + +3. 勾选上,**纹理图集**的打包复选框 + + + +4. 点击 **打包设置** + +这里建议勾选 **2 的幂数;预乘和溢出两项请勿勾选** +完成打包设置后,点击**确定** + + +5. 回到导出窗口,选择导出文件夹后,点击**导出** + + + +6. 将会得到三个如下文件: + + + +spineboy.skel 包含了 skeleton animation 数据,spineboy.atlas 包含了 texture atlas 信息,导出的图片可能有多张,每张图片都代表了 texture altas 中的一页 + +### 在 Galacean 编辑器中导入资产 +打开编辑器后,将导出的文件直接拖入到[资产面板](https://antg.antgroup.com/engine/docs/latest/cn/assets-interface)中,完成上传 -### 资源导出 + -下载 [Spine 编辑器](https://zh.esotericsoftware.com/),并选择 3.8 版本制作动画(目前仅支持 3.8 版本)。通过 spine 编辑器的导出功能能够导出所需的资源文件。导出后,在目标文件夹内会看到 .json(或者.bin), atlas, png 三种格式的资源文件。[点击下载示例文件](https://mdn.alipayobjects.com/portal_h1wdez/afts/file/A*uhFUSbeI5z0AAAAAAAAAAAAAAQAAAQ) +也可以点击资产面板的上传按钮进行上传: -> Galacean Spine 运行时目前只支持加载单张纹理,所以当贴图尺寸过大时,需要对图片资源进行缩放处理,把贴图的张数控制在一张。 -> 文件导出的详细配置见 spine 官方文档:[http://zh.esotericsoftware.com/spine-export](http://zh.esotericsoftware.com/spine-export/) + -### 资源上传 +上传完成后,在资产面板中能够看到上传的 spine 素材 -资源导出后,开发者需要同时把三个文件上传到 Galacean Editor。通过 **[资产面板](/docs/assets-interface)** 的上传按钮选择 “spine” 资产,选择本地的这三个文件,上传成功后能够在资产面板看到上传的 spine 资产: - -也可以直接把三个文件拖动到资产区域完成上传: - +#### SpineSkeletonData 资产 -完成上传后,能够在 Asset 面板看到上传的 spine 素材: - + +SpineSkeletonData 资产存储了 skeleton 数据,以及对生成的 SpineAtlas 资产的引用 +点击资产后,能够在检查器中预览 Spine 动画,预览面板中能够切换皮肤和动画片段: + + + +#### SpineAtlas 资产 + + + +SpineAtlas 资产存储了texture atlas 文件,并包含了其对所需 Texture 资产的引用。 +点击资产后,能够在检查器中查看其引用的 Texture 资产,以及 Spine 的图集信息 + + + +### 资产更新 +如若需要更新你的 Spine 资产,直接覆盖这些文件即可完成更新。从 Spine 编辑器中重新导出资产,并再次导入到 Galacean 编辑器中覆盖原有文件即可。 + + +## 使用 Spine 组件 ### 添加组件 +完成资产上传后,可以通过添加 Spine 组件,将 Spine 动画添加到场景中。一共有三种方式添加: + +1. 拖入添加 + +拖入添加是最快捷的一种方式。点击 SpineSkeletonData 资产,按住后拖动到视图区,就能快速创建一个添加了 Spine 组件的实体,并指定资产为刚刚选中的资产。 + + -完成资源上传后,在编辑器左侧节点树中添加一个 spine 渲染节点(一个自带 SpineRenerer 组件的节点),选择 resource 为上一步上传的资产,选择动画名称即可播放 spine 动画(如果不选择,默认第一个)。 +2. 快速添加 -![spine](https://mdn.alipayobjects.com/huamei_w6ifet/afts/img/A*tqm4R51gYxEAAAAAAAAAAAAADjCHAQ/original) +点击左上角的快速添加按钮,选择 `2D Object`>`SpineAnimationRenderer`, -Spine 渲染组件的属性如下: + -| 属性 | 功能说明 | -| :---------- | :----------------------------------------------- | -| `Resource` | 选择 Spine 资产 | -| `AutoPlay` | 是否自动播放 | -| `loop` | 是否循环播放 | -| `Animation` | 动画名称 | -| `SkinName` | 皮肤名称 | -| `Scale` | 动画缩放 | -| `priority` | 渲染优先级,值越小,渲染优先级越高,越优先被渲染 | +添加完成后,能够看到一个新的实体,挂载了 Spine 组件;点击 Resource 属性,选择上传的 SpineSkeletonData 资产,就能看到 Spine 动画啦 -### 编写脚本 + -如果需要对 spine 动画进行额外的逻辑编写,就需要借助编辑器的脚本功能了。创建一个脚本资源,并给上一小节创建的节点添加一个脚本组件,选择创建好的脚本。 - +3. 手动添加 -双击素材面板中的脚本,或者点击脚本组件的编辑按钮,能够进入脚本编辑器,在脚本编辑器中可以从当前 entity 上获取 spine 渲染组件,通过组件 API 进行更多操作。比如,主动播放某一段动画: - +手动添加的方式与快速添加类似,不过需要创建一个新的实体,并通过检查器的 AddComponent 按钮添加 Spine 组件 -更详细的 API 请参考下面的章节。 + -## 在代码中使用 +添加了 Spine 组件后,同样需要指定组件的 Resource,也就是 Spine 组件要渲染的 SpineSkeletonData 资产。 -### 安装 +### 组件配置 +点击实体,能够在检查器中看到 Spine 组件的配置项。![image.png](https://intranetproxy.alipay.com/skylark/lark/0/2024/png/76063/1721269455151-ceaf0b7e-a00b-4ba4-a109-0cdb08c135f2.png#clientId=uf07edf19-371c-4&from=paste&height=199&id=ucac2f7de&originHeight=398&originWidth=1060&originalType=binary&ratio=2&rotation=0&showTitle=false&size=82934&status=done&style=shadow&taskId=ub50cded7-5559-41de-b104-9db3c4bb4f5&title=&width=530) +通过 Spine 组件能够配置 Spine 动画的默认状态。各属性含义如下: -首先需要手动添加 [@galacean/engine-spine](https://github.com/galacean/engine-spine) 二方包。 +- Resource:Spine 动画的资源,即 SpineSkeletonData 资产 +- Animation:默认播放的动画名称 +- Loop:默认播放的动画是否循环 +- Skin:默认的皮肤名称 +- Scale:默认的缩放系数 +- Priority:渲染优先级 +### 组件 API +除了在编辑器中配置默认状态外,在[脚本](https://antg.antgroup.com/engine/docs/latest/cn/script)中能够更加灵活的操作。Spine 组件暴露了两个重要的 API 来进行动画控制与骨架操作,以实现更加复杂的效果。 +#### 动画控制 AnimationState +在脚本中,你能够通过以下方式获取到 [AnimationState](https://zh.esotericsoftware.com/spine-api-reference#AnimationState) 对象,来控制 Spine 的动画播放逻辑。 +```typescript +class YourAmazingScript { -```bash -npm i @galacean/engine-spine --save + onStart() { + const spine = this.entity.getComponent(SpineAnimationRenderer); + const { state } = spine; // AnimationState 对象 + } + +} +``` +##### 播放动画 +首先,我们来介绍一下最常用的 API:[setAnimation](https://zh.esotericsoftware.com/spine-api-reference#AnimationState-setAnimation) +```typescript +state.setAnimation(0, 'animationName', true) +``` +setAnimation 函数接受三个参数: + +- TrackIndex:动画轨道序号 +- animationName:动画名称 +- loop:是否循环播放 + +后两个参数很好理解,第一个参数则包含了 Spine 动画的一个概念:**Track** (轨道) +> Spine 动画在播放时,需要指定一个动画轨道。借助动画轨道,Spine 能够分层应用动画,每一个轨道都能够存储动画与播放参数,轨道的编号从 0 开始累加。在动画应用后,Spine 会从低轨道到高轨道依次应用动画,高轨道上的动画将会覆盖低轨道上的动画。 +动画轨道有很多用途,例如,轨道 0 可以有行走、奔跑、游泳或其他动画,轨道 1 可以有一个只为手臂和开枪设置了关键帧的射击动画。此外,为高层轨道设置TrackEntry alpha可使其与下面的轨道混合。例如,轨道 0 可以有一个行走动画,轨道 1 可以有一个跛行动画。当玩家受伤时,增加轨道 1 的alpha值,跛行就会加重。 + + +##### 设置过渡 +调用 setAnimation 方法后,会立即切换当前轨道的动画。如果你需要动画切换时有过渡效果,就需要设置过渡的持续时间了。这时就需要 [AnimationStateData](https://zh.esotericsoftware.com/spine-api-reference#AnimationStateData) 的 API 来进行设置了: +```typescript +class YourAmazingScript { + + onStart() { + const spine = this.entity.getComponent(SpineAnimationRenderer); + const { state } = spine; // AnimationState 对象 + const { data } = state; // AnimationStateData 对象 + data.defaultMix = 0.2; + data.setMix('animationA', 'animationB', 0.3); + } + +} ``` -### 资源导出 +- defaultMix 是当两个动画间没有定义混合持续时间时的默认持续时间 +- setMix 函数接受三个参数,前两个是需要设置过渡时间的动画名称,第三个则是动画混合的持续时间 +##### 动画队列 +Spine 还提供了 [addAnimation](https://zh.esotericsoftware.com/spine-api-reference#AnimationState-addAnimation2) 方法来实现动画的队列播放: +```typescript +state.setAnimation(0, 'animationA', false); +state.addAnimation(0, 'animationB', true, 0); +``` +addAnimation 接受 4 个参数: -安装了二方包后,与在编辑器中使用一样,需要下载 [Spine 编辑器](https://zh.esotericsoftware.com/),并选择 3.8 版本制作动画(目前仅支持 3.8 版本)。通过 spine 编辑器的导出功能能够导出所需的资源文件。导出后,在目标文件夹内会看到 .json(或者.bin), atlas, png 三种格式的资源文件。[点击下载示例文件](https://mdn.alipayobjects.com/portal_h1wdez/afts/file/A*uhFUSbeI5z0AAAAAAAAAAAAAAQAAAQ) +- TrackIndex:动画轨道 +- animationName:动画名称 +- loop:是否循环播放 +- delay:延迟时间 -### 资源加载 +前三个参数很好理解,这里解释一下第四个参数: +delay 代表了前一个动画的持续时间。 +当 delay > 0 时(假设 delay 为 1),前一个动画会在播放 1 秒后,切换到下一个动画。如下图所示: -在代码中,引入了 _@galacean/engine-spine_ 后,会自动在 [engine]($%7Bapi%7Dcore/Engine) 的 [resourceManager]($%7Bapi%7Dcore/Engine#resourceManager) 上注册 spine 资源的资源加载器。通过 resourceManager 的 [load]($%7Bapi%7Dcore/ResourceManager/#load) 方法能够加载 spine 动画资源。 + -- 当传递参数为 url 时,默认 spine 动画的资源拥有同样的 baseUrl,仅需传递 json(或者 bin) 文件的 cdn 即可。 -- 当传递参数为 urls 数组时,需要传递 json(或者 bin),atlas, image(png,jpg)三个资源的 cdn 地址。 -- 资源的 type 必须指定为 spine。 +如果动画 A 的时长小于 1 秒,则会根据是否设置了循环播放:循环播放直至 1 秒,或者播放完毕后,保持在动画播放完毕的状态直至 1 秒。 +当 delay = 0 时,下一个动画会在前一个动画播放完毕后播放,如下图所示: -加载完毕后,会返回一个 SpineResouce。我们需要创建一个节点,添加 Spine 渲染器,然后指定渲染器的资源为返回的 SpineResouce。请参考下方示例中的代码: + - +假设动画 A 的时长为 1 秒,过渡持续时间为 0.2 秒,当 delay 设置为 0 时,动画 B 会从 1 - 0.2 也就是 0.8 秒开始过渡到动画 B。 +当 delay < 0 时,上一个动画未播放完毕前,下一个动画就会开始播放,如下图所示: +同样假设动画 A 的时长为 1 秒,过渡持续时间为 0.2 秒,动画 B 则会从 0.6 秒开始过渡到动画 B。 -### 动画播放 + -Spine 渲染器(SpineRenderer) 提供了多种动画播放的方法。 +除了 addAnimation 外,还能够通过 addEmptyAnimation 方法添加空动画。空动画能够让动画回到初始状态。addEmptyAnimation 接受三个参数:TrackIndex,mixDuration 和 delay。TrackIndex 和 delay 参数与 addAnimation 一样。 mixDuration 是过渡持续时间,动画会逐渐回到初始状态。如下图所示(右侧棕色区域即是空动画), -1. 通过 animationName,autoPlay,loop 属性播放。当设置了 animationName 为待播放的动画名称,且 autoplay 为 true 时,对应名称动画会自动播放。通过 loop 能够控制是否循环播放。 + -```javascript -const spineRenderer = spineEntity.getComponent(SpineRenderer); -spineRenderer.animationName = "idle"; -spineRenderer.autoPlay = true; -spineRenderer.loop = true; -``` +##### 轨道参数 +setAnimation 和 addAnimation 方法都会返回一个对象:TrackEntry。TrackEntry 提供了更多的参数来进行动画控制。 +例如: + +- timeScale:控制动画播放的速度 +- animationStart:控制动画播放的开始时间 +- apha:当前动画应用轨道的混合系数 + +更多参数可以参考 [TrackEntry 官方文档](https://zh.esotericsoftware.com/spine-api-reference#TrackEntry) +##### 动画事件 -2. 调用 play 方法播放。play 方法支持传入动画名称和是否循环两个参数。 + -```javascript -const spineRenderer = spineEntity.getComponent(SpineRenderer); -spineRenderer.play("idle", true); +当调用 AnimationState API 进行动画控制时,会触发如上图所示的事件。在新的动画开始播放时,会触发 Start 事件,当动画在动画队列中移除或者中断时,会触发 End 事件。当动画播放完毕时,无论是否循环,都会触发 Complete 事件。 +全部的事件以及详细解释请参考:[Spine 动画事件官方文档](https://zh.esotericsoftware.com/spine-unity-events) +这些事件能够通过 [AnimationState.addListener](https://zh.esotericsoftware.com/spine-api-reference#AnimationState-addListener) 进行监听。 +```typescript +class YourAmazingScript { + + onStart() { + const spine = this.entity.getComponent(SpineAnimationRenderer); + const { state } = spine; // AnimationState 对象 + state.addListener({ + start: (entry: TrackEntry) => { + // call back function + }, + complete: (entry: TrackEntry) => { + // call back function + }, + // 还有 end,interrupt,dispose,event 等事件 + // end, interrupt, dispose, event + }) + } + +} ``` -3. 从 spineAnimation 属性上,能够获取 spine 的 [AnimationState](http://zh.esotericsoftware.com/spine-api-reference#AnimationState) 以及 [Skeleton](http://zh.esotericsoftware.com/spine-api-reference#Skeleton) 接口,能够借助 spine-core 原生 API 来播放动画。 +#### 骨架操作 Skeleton +在脚本中,你能够通过以下方式获取到 [Skeleton](https://zh.esotericsoftware.com/spine-api-reference#Skeleton) 对象,来访问骨骼、插槽、附件等等,并进行各类操作。 +```typescript +class YourAmazingScript { + + onStart() { + const spine = this.entity.getComponent(SpineAnimationRenderer); + const { skeleton } = spine; // Skeleton 对象 + } + +} +``` +下面是一些常用的操作。 +##### 修改骨骼位置 +通过 Skeleton API 能够修改 Spine 骨骼的位置,比较常见的应用是:可以通过设置 IK 的目标骨骼,来实现瞄准/跟随效果。 +```typescript +class YourAmazingScript { + + onStart() { + const spine = this.entity.getComponent(SpineAnimationRenderer); + const { skeleton } = spine; // Skeleton 对象 + const bone = skeleton.findBone('aim-target'); + bone.x = targetX; + bone.y = targetY; + } + +} +``` +‼️ 由于应用动画会修改骨骼位置,所以如果 Spine 在播放动画, 那么骨骼位置的修改需要在应用动画之后,也就是在脚本的 onLateUpdate 生命周期中进行操作。 + +##### 附件更换 +通过 Skeleton API 能够替换[插槽](https://zh.esotericsoftware.com/spine-slots)内的[附件](https://zh.esotericsoftware.com/spine-attachments)。通过切换附件,能够实现局部换装的效果。 +```typescript +class YourAmazingScript { + + onStart() { + const spine = this.entity.getComponent(SpineAnimationRenderer); + const { skeleton } = spine; // Skeleton 对象 + // 根据名称查找插槽 + const slot = skeleton.findSlot('slotName'); + // 按名称从骨架皮肤或默认皮肤获取附件 + const attachment = skeleton.getAttachment(slot.index, 'attachmentName'); + // 设置插槽附件 + slot.attachment = attachment; + // 或者由骨架setAttachment方法来设置插槽附件 + skeleton.setAttachment('slotName', 'attachmentName'); + } +} +``` +‼️ 由于应用动画会修改插槽内的附件,所以如果 Spine 在播放动画,那么附件更换的操作需要在应用动画之后,也就是在脚本的 onLateUpdate 生命周期中进行操作。 +##### 换肤与混搭 +**换肤** +通过 Skeleton 的 [setSkin](https://zh.esotericsoftware.com/spine-api-reference#Skeleton-setSkin) API 能够根据皮肤名称实现整体换肤。 +```typescript +class YourAmazingScript { + + onStart() { + const spine = this.entity.getComponent(SpineAnimationRenderer); + const { skeleton } = spine; // Skeleton 对象 + // 根据皮肤名称设置皮肤 + skeleton.setSkinByName("full-skins/girl"); + // 回到初始位置(必须调用,否则渲染可能出现错乱) + skeleton.setSlotsToSetupPose(); + } + +} +``` +**混搭** +在 Spine 编辑器中,设计师可以为每一个外观和装备准备皮肤,然后在运行时把他们组合成一个新的皮肤。下面的代码展示了如果通过 addSkin 来添加选定的皮肤的: +```typescript +class YourAmazingScript { + + onStart() { + const spine = this.entity.getComponent(SpineAnimationRenderer); + const { skeleton } = spine; // Skeleton 对象 + const mixAndMatchSkin = new spine.Skin("custom-girl"); + mixAndMatchSkin.addSkin(skeletonData.findSkin("skin-base")); + mixAndMatchSkin.addSkin(skeletonData.findSkin("nose/short")); + mixAndMatchSkin.addSkin(skeletonData.findSkin("eyelids/girly")); + mixAndMatchSkin.addSkin(skeletonData.findSkin("eyes/violet")); + mixAndMatchSkin.addSkin(skeletonData.findSkin("hair/brown")); + mixAndMatchSkin.addSkin(skeletonData.findSkin("clothes/hoodie-orange")); + mixAndMatchSkin.addSkin(skeletonData.findSkin("legs/pants-jeans")); + mixAndMatchSkin.addSkin(skeletonData.findSkin("accessories/bag")); + mixAndMatchSkin.addSkin(skeletonData.findSkin("accessories/hat-red-yellow")); + this.skeleton.setSkin(mixAndMatchSkin); + } + +} +``` +代码中皮肤的名称来自于官方的 mix-and-match 示例,该示例可以在下面的章节看到。 -```javascript -const spineRenderer = spineEntity.getComponent(SpineRenderer); -spineRenderer.spineAnimation.state.setAnimation(0, "idle", true); +# 在代码中使用 Galacean Spine 运行时 +无论是通过编辑器下载的项目,或者是 procode 项目,都需要通过安装 @galacean/engine-spine 来注册 Spine 组件以实现 Spine 动画的加载和渲染。 +## 安装 +```typescript +npm install @galacean/engine-spine --save ``` -#### 动画控制 +## 加载 spine 资产 +引入 @galacean/engine-spine 后,就能通过引擎的 resourceManager 加载 Spine 资产,并创建 Spine 动画了。Galacean Spine 加载器支持多方式加载 Spine 资产: +### 加载 Galacean 编辑器中的上传的资产 +[导出编辑器项目后](https://antg.antgroup.com/engine/docs/latest/cn/assets-build),已添加至场景中的 Spine 动画,会在项目运行后自动加载。 +如需动态加载 Spine 动画可以按照如下步骤,在代码中进行加载: + +1. 找到 Spine 动画的资产链接 -借助 Spine 渲染器(SpineRenderer) 的 spineAnimation 暴露的 AnimationState 对象,能够实现动画的控制,比如循环播放动画,暂停动画播放等。这里可以参考下面的示例。 -详细的 API 可以参考 AnimationState 的官方文档:[http://zh.esotericsoftware.com/spine-api-reference#AnimationState](http://zh.esotericsoftware.com/spine-api-reference#AnimationState) + -### 动画事件机制 +点击 Galacean 编辑器的下载按钮,选择 project URL,拷贝 project.json 后打开,找到上传的 spine 动画文件(skel / json): -spine 还提供了一些事件方便用户进行开发。动画事件的机制如下图所示: -![](https://gw.alipayobjects.com/mdn/mybank_yul/afts/img/A*fC1NT5tTET8AAAAAAAAAAAAAARQnAQ#crop=0&crop=0&crop=1&crop=1&id=JUZeZ&originHeight=280&originWidth=640&originalType=binary&ratio=1&rotation=0&showTitle=false&status=done&style=none&title=) -详细文档: -[http://esotericsoftware.com/spine-unity-events](http://esotericsoftware.com/spine-unity-events) -通过 AnimationState 的 addListener 方法,能够在不同的事件触发时,添加回调方法。 + -### 换肤 +2. 使用 resourceManager 加载 +```typescript +import { SpineAnimationRenderer } from '@galacean/engine-spine'; -运行时提供了多种方法来进行换肤。最简单的方式是通过 Spine 渲染器的 skinName 属性进行换肤。 +// 初始化 galacean -```javascript -const spineRenderer = spineEntity.getComponent(SpineRenderer); -spineRenderer.skinName = "skin1"; +// 加载 spine 资产 +const resource = await engine.resourceManager.load( + { + url: 'https://galacean.spineboy.json', // 编辑器资产 + type: 'spine', + }, +); +// 创建实体 +const spineEntity = new Entity(engine); +// 添加 spine 组件 +const spine = spineEntity.addComponent(SpineAnimationRenderer); +// 设置默认动画 +spine.defaultState.animatitonName = 'your-default-animation'; +// 添加至场景 +root.addChild(spineEntity); + +``` +### 加载自定义上传的资产 +如果你的 Spine 资产未通过 Galacean 编辑器进行上传,同样能够通过 Galacean Spine 加载器进行加载。 +```typescript +const resource = await engine.resourceManager.load( + { + url: 'https://your.spineboy.json', // 自定义上传的资产 + type: 'spine', + }, +); ``` -也可以通过 spine-core 原生 API 来进行换肤。请参考下方示例: - +- 当传递一个 url 地址时,需要确保 atlas 和 texture 资源在相同目录下,即: + +https://your.spineboy.atlas,https://your.spineboy.png 文件需要存在。 + +- 若资源不在相同目录下,还可以通过传递 urls 来进行加载: +```typescript +const resource = await engine.resourceManager.load( + { + urls: [ + 'https://your.spineboy.json', + 'https://ahother-path1.spineboy.altas', + 'https://ahother-path2.spineboy.png', + ], // 自定义上传的资产 + type: 'spine', + }, +); +``` +若不传递 texture 地址,那么加载器会从 atlas 文件中读区 texture 图片名称,并从 atlas 的相对路径下查找 texture 资源。 +若自定上传的资产没有文件后缀(比如 blob 协议的 URL),则可以通过给链接添加 URL query 参数,例如: +https://your.spineboyjson?q=.json,https://your.spineboyatlas?q=.atlas,或者添加 fileExtension 参数来指定资源后缀类型: +```typescript +const resource = await engine.resourceManager.load( + { + urls: [ + 'https://your.spineboyjson', + 'https://ahother-path1.spineboyatlas', + 'https://ahother-path2.spineboypng', + ], // 自定义上传的资产 + type: 'spine', + fileExtensions: [ + 'json', + 'atlas', + 'png', + ] + }, +); +``` + +# 示例 +动画控制 + -#### 附件替换 +跟踪射击 + -借助原生 API 能够实现 spine 的附件替换,从而实现部分换装的效果。请参考下方示例: +局部换肤 -#### 插槽拆分 +整体换肤 + + +皮肤混搭 + + +物理 + + +# Spine 版本 +@galacen/engine-spine 自 1.2 版本后开始支持 spine 4.x 版本。@galacen/engine-spine 包的 major version 和 minor version 与 spine 版本完全对应,版本对照如下: +
@galacean/engine-spine < 1.2 对应 spine version 3.8 +
@galacean/engine-spine 4.0 对应 spine version 4.0 +
@galacean/engine-spine 4.1 对应 spine version 4.1 +
@galacean/engine-spine 4.2 对应 spine version 4.2 +
..... +# 版本升级 +升级到编辑器 1.3 版本后。除了需要在编辑器的[项目设置](https://antg.antgroup.com/engine/docs/latest/cn/interface-menu#%E9%A1%B9%E7%9B%AE%E8%AE%BE%E7%BD%AE)中升级引擎版本外,由于导出 JSON 或者二进制的 Spine 编辑器版本需要与运行时版本[保持一致](https://zh.esotericsoftware.com/spine-versioning#%E5%90%8C%E6%AD%A5%E7%89%88%E6%9C%AC),所以编辑器升级到 1.3 后,还需要重新导出 4.2 版本的 Spine 资产并上传到编辑器,通过文件覆盖完成资产的更新。 + +# Spine 性能建议 +这里提供一些优化 spine 动画性能的方法: + +1. 使用二进制文件(.skel)的形式导出 skeleton,二进制文件的体积更小,加载更快。 +2. 建议将附件打包到尽可能少的atlas页中, 并根据绘制顺序将附件分组置入atlas页以防止多余的material切换. 请参考:[Spine 纹理打包:文件夹结构](https://zh.esotericsoftware.com/spine-texture-packer#%E6%96%87%E4%BB%B6%E5%A4%B9%E7%BB%93%E6%9E%84)了解如何在你的Spine atlas中编排 atlas 区域。 +3. 少用裁减功能。Spine 的裁减实现是通过动态裁减三角形实现的,性能开销很大。 +4. 尽可能少地使用atlas page textures。即,导出是贴图的数量尽可能控制在一张。 + + + + + -spine 组件会合并 spine 动画的所有顶点生成一个 `Mesh`。使用 `addSeparateSlot` 方法能够将指定名称的插槽拆分成单独的 `SubMesh`,然后使用 `hackSeparateSlotTexture` 方法,能够替换拆分插槽的材质。通过这种方式,也能实现局部换装的效果。请参考下方示例: - diff --git a/examples/spine-animation.ts b/examples/spine-animation.ts index bc44ad91a3..69326c6d1c 100644 --- a/examples/spine-animation.ts +++ b/examples/spine-animation.ts @@ -3,8 +3,8 @@ * @category 2D * @thumbnail https://mdn.alipayobjects.com/merchant_appfe/afts/img/A*IALeTYOXMXwAAAAAAAAAAAAADiR2AQ/original */ -import { Camera, Logger, Vector3, WebGLEngine, Entity } from "@galacean/engine"; -import { SpineRenderer } from "@galacean/engine-spine"; +import { Camera, Entity, Logger, Vector3, WebGLEngine } from "@galacean/engine"; +import { SpineAnimationRenderer } from "@galacean/engine-spine"; Logger.enable(); @@ -18,20 +18,43 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { // camera const cameraEntity = rootEntity.createChild("camera_node"); const camera = cameraEntity.addComponent(Camera); - cameraEntity.transform.position = new Vector3(0, 0, 60); + cameraEntity.transform.position = new Vector3(0, 0, 100); + camera.nearClipPlane = 0.001; + camera.farClipPlane = 20000; engine.resourceManager .load({ - url: "https://mmtcdp.stable.alipay.net/oasis_be/afts/file/A*jceoSrUXbUYAAAAAAAAAAAAADnN-AQ/spineboy.json", + url: "https://mdn.alipayobjects.com/huamei_kz4wfo/uri/file/as/2/kz4wfo/4/mp/qGISZ7QTJFkEL0Qx/spineboy/spineboy.json", type: "spine", }) .then((spineResource: any) => { - const spineEntity = rootEntity.createChild("spine"); + const spineEntity = new Entity(engine); spineEntity.transform.setPosition(0, -18, 0); - const spineRenderer = spineEntity.addComponent(SpineRenderer); - spineRenderer.scale = 0.05; - spineRenderer.animationName = "walk"; - spineRenderer.resource = spineResource; + const spine = spineEntity.addComponent(SpineAnimationRenderer); + spine.resource = spineResource; + spine.defaultState.scale = 0.05; + rootEntity.addChild(spineEntity); + const { state } = spine; + state.data.defaultMix = 0.3; + state.data.setMix('death', 'portal', 0); + const queue = () => { + state.setAnimation(0, 'portal', false); + state.addAnimation(0, 'idle', true, 0); + state.addAnimation(0, 'walk', true, 1); + state.addAnimation(0, 'run', true, 2); + state.addAnimation(0, 'jump', false, 2); + state.addAnimation(0, 'death', false, 0); + }; + queue(); + state.addListener({ + complete: (entry) => { + if (entry?.animation?.name === 'death') { + setTimeout(() => { + queue(); + }, 1000); + } + } + }); }); engine.run(); diff --git a/examples/spine-change-attachment.ts b/examples/spine-change-attachment.ts index cea58fb2fa..d8303df57f 100644 --- a/examples/spine-change-attachment.ts +++ b/examples/spine-change-attachment.ts @@ -1,16 +1,13 @@ /** - * @title Spine Change Attachment + * @title Spine change Attachment * @category 2D - * @thumbnail https://mdn.alipayobjects.com/merchant_appfe/afts/img/A*VUeSSbViZe8AAAAAAAAAAAAADiR2AQ/original + * @thumbnail https://mdn.alipayobjects.com/merchant_appfe/afts/img/A*IALeTYOXMXwAAAAAAAAAAAAADiR2AQ/original */ -import { Camera, Logger, Vector3, WebGLEngine, Entity } from "@galacean/engine"; -import { SpineRenderer } from "@galacean/engine-spine"; -import * as dat from "dat.gui"; +import { Camera, Entity, Logger, Vector3, WebGLEngine } from "@galacean/engine"; +import { SpineAnimationRenderer } from "@galacean/engine-spine"; Logger.enable(); -const gui = new dat.GUI(); - // Create engine WebGLEngine.create({ canvas: "canvas" }).then((engine) => { engine.canvas.resizeByClientSize(); @@ -21,58 +18,33 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { // camera const cameraEntity = rootEntity.createChild("camera_node"); const camera = cameraEntity.addComponent(Camera); - cameraEntity.transform.position = new Vector3(0, 0, 60); + cameraEntity.transform.position = new Vector3(0, 0, 100); + camera.nearClipPlane = 0.001; + camera.farClipPlane = 20000; engine.resourceManager .load({ - urls: [ - "https://gw.alipayobjects.com/os/OasisHub/01c23386-ae6d-41b3-ab51-08b023a0dc3f/1629864253199.json", - "https://gw.alipayobjects.com/os/OasisHub/27b76dd2-01b3-4282-83e8-17be20b910ae/1629864253200.atlas", - "https://gw.alipayobjects.com/zos/OasisHub/99bc4468-02c6-4f35-8fef-ac5a711fc641/1629864253200.png", - ], + url: "https://mdn.alipayobjects.com/huamei_kz4wfo/uri/file/as/2/kz4wfo/4/mp/24ejL92gvbWxsXRi/mix-and-match/mix-and-match.json", type: "spine", }) .then((spineResource: any) => { - const spineEntity = rootEntity.createChild("spine"); + const spineEntity = new Entity(engine); + spineEntity.transform.setPosition(0, -18, 0); + const spine = spineEntity.addComponent(SpineAnimationRenderer); + spine.resource = spineResource; + spine.defaultState.scale = 0.05; + spine.defaultState.skinName = 'full-skins/girl'; + spine.defaultState.animationName = 'idle'; rootEntity.addChild(spineEntity); - const spineRenderer = spineEntity.addComponent(SpineRenderer); - spineRenderer.scale = 0.05; - spineRenderer.animationName = "walk"; - spineRenderer.resource = spineResource; - const { skeleton, state, skeletonData } = spineRenderer.spineAnimation; - spineEntity.transform.setPosition(0, -10, 0); - state.setAnimation(0, "sneering", true); - skeleton.setSkinByName("fullskin/0101"); // 1. Set the active skin - skeleton.setSlotsToSetupPose(); // 2. Use setup pose to set base attachments. - state.apply(skeleton); - const slotName = "fBody"; - const info = { - 更换衣服部件: "fullskin/0101", - }; - gui - .add(info, "更换衣服部件", [ - "fullskin/0101", - "fullskin/autumn", - "fullskin/carnival", - "fullskin/fishing", - "fullskin/football", - "fullskin/newyear", - "fullskin/painter", - "fullskin/snowman", - ]) - .onChange((skinName) => { - const currentSkin = skeleton.skin; - const slotIndex = skeleton.findSlotIndex(slotName); - const changeSkin = skeletonData.findSkin(skinName); - const changeAttachment = changeSkin.getAttachment( - slotIndex, - slotName - ); - if (changeAttachment) { - currentSkin.removeAttachment(slotIndex, slotName); - currentSkin.setAttachment(slotIndex, slotName, changeAttachment); - } - }); + const { skeleton } = spine; + const slot = skeleton.findSlot('body')!; + // If the attachment is in the same slot of the same skin, + // you can use skeleton.getAttachment(slot.index, 'attachmentName') to get the attachment from the currentSkin or defaultSkin. + const skin = skeleton.data.findSkin('full-skins/boy')!; + const attachment = skin.getAttachment(slot.data.index, 'body'); + // If the attachment is in the same slot of the same skin, + // you can use skeleton.setAttachment('slotName', 'attachmentName') to change the attachment. + slot.attachment = attachment; }); engine.run(); diff --git a/examples/spine-follow-shoot.ts b/examples/spine-follow-shoot.ts new file mode 100644 index 0000000000..59fc5c3369 --- /dev/null +++ b/examples/spine-follow-shoot.ts @@ -0,0 +1,75 @@ +/** + * @title Spine follow shoot + * @category 2D + * @thumbnail https://mdn.alipayobjects.com/merchant_appfe/afts/img/A*IALeTYOXMXwAAAAAAAAAAAAADiR2AQ/original + */ +import { Camera, Entity, Logger, Script, Vector3, WebGLEngine } from "@galacean/engine"; +import { Bone, SpineAnimationRenderer } from "@galacean/engine-spine"; + +Logger.enable(); + +// Create engine +WebGLEngine.create({ canvas: "canvas" }).then((engine) => { + engine.canvas.resizeByClientSize(); + + const scene = engine.sceneManager.activeScene; + const rootEntity = scene.createRootEntity(); + + // camera + const cameraEntity = rootEntity.createChild("camera_node"); + const camera = cameraEntity.addComponent(Camera); + cameraEntity.transform.position = new Vector3(0, 0, 100); + camera.nearClipPlane = 0.001; + camera.farClipPlane = 20000; + + engine.resourceManager + .load({ + url: "https://mdn.alipayobjects.com/huamei_kz4wfo/uri/file/as/2/kz4wfo/4/mp/yKbdfgijyLGzQDyQ/spineboy/spineboy.json", + type: "spine", + }) + .then((spineResource: any) => { + const spineEntity = new Entity(engine); + spineEntity.transform.setPosition(0, -18, 0); + const spine = spineEntity.addComponent(SpineAnimationRenderer); + spine.resource = spineResource; + spine.defaultState.scale = 0.05; + rootEntity.addChild(spineEntity); + const { state, skeleton } = spine; + state.setAnimation(0, 'idle', true); + state.setAnimation(1, 'aim', true); + const shoot = () => { + state.setAnimation(2, 'shoot', false); + }; + spineEntity.addComponent(class extends Script { + private _vec3 = new Vector3(); + onUpdate(): void { + const { inputManager } = engine; + const pointers = inputManager.pointers; + if (pointers.length > 0) { + const { position } = pointers[0]; + const worldPos = this._vec3; + camera.screenToWorldPoint( + new Vector3(position.x, position.y, 2000), + worldPos, + ); + const targetBone = skeleton.findBone('crosshair') as Bone;targetBone.y = worldPos.y + 380; + targetBone.y = worldPos.y + 380; + if (worldPos.x < 0) { + skeleton.scaleX = -0.05; + targetBone.x = -worldPos.x; + } else { + skeleton.scaleX = 0.05; + targetBone.x = worldPos.x; + } + } + if (inputManager.isPointerDown()) { + shoot(); + } + } + }); + }); + + engine.run(); +}); + + diff --git a/examples/spine-full-skin-change.ts b/examples/spine-full-skin-change.ts new file mode 100644 index 0000000000..fce1e9b3c8 --- /dev/null +++ b/examples/spine-full-skin-change.ts @@ -0,0 +1,64 @@ +/** + * @title Spine Full Skin Change + * @category 2D + * @thumbnail https://mdn.alipayobjects.com/merchant_appfe/afts/img/A*IALeTYOXMXwAAAAAAAAAAAAADiR2AQ/original + */ +import { Camera, Entity, Logger, Vector3, WebGLEngine } from "@galacean/engine"; +import { SpineAnimationRenderer } from "@galacean/engine-spine"; +import * as dat from "dat.gui"; + +Logger.enable(); + +const gui = new dat.GUI(); + +// Create engine +WebGLEngine.create({ canvas: "canvas" }).then((engine) => { + engine.canvas.resizeByClientSize(); + + const scene = engine.sceneManager.activeScene; + const rootEntity = scene.createRootEntity(); + + // camera + const cameraEntity = rootEntity.createChild("camera_node"); + const camera = cameraEntity.addComponent(Camera); + cameraEntity.transform.position = new Vector3(0, 0, 100); + camera.nearClipPlane = 0.001; + camera.farClipPlane = 20000; + + engine.resourceManager + .load({ + url: "https://mdn.alipayobjects.com/huamei_kz4wfo/uri/file/as/2/kz4wfo/4/mp/24ejL92gvbWxsXRi/mix-and-match/mix-and-match.json", + type: "spine", + }) + .then((spineResource: any) => { + const spineEntity = new Entity(engine); + spineEntity.transform.setPosition(0, -18, 0); + const spine = spineEntity.addComponent(SpineAnimationRenderer); + spine.resource = spineResource; + spine.defaultState.scale = 0.05; + spine.defaultState.skinName = 'full-skins/girl'; + spine.defaultState.animationName = 'idle'; + rootEntity.addChild(spineEntity); + const { skeleton, state } = spine; + const info = { + skin: "full-skins/girl", + }; + gui + .add(info, "skin", [ + "full-skins/girl", + "full-skins/girl-blue-cape", + "full-skins/girl-spring-dress", + "full-skins/boy", + ]) + .onChange((skinName) => { + skeleton.setSkinByName(skinName); + skeleton.setSlotsToSetupPose(); + state.data.defaultMix = 0.2; + state.setAnimation(0, 'dress-up', false); + state.addAnimation(0, 'idle', true, 0); + }); + + }); + + engine.run(); +}); diff --git a/examples/spine-hack-slot-texture.ts b/examples/spine-hack-slot-texture.ts deleted file mode 100644 index 7bcd7b133e..0000000000 --- a/examples/spine-hack-slot-texture.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** - * @title Spine Hack Slot Texture - * @category 2D - * @thumbnail https://mdn.alipayobjects.com/merchant_appfe/afts/img/A*_wfjSYhksRYAAAAAAAAAAAAADiR2AQ/original - */ -import { - Camera, - Logger, - Vector3, - WebGLEngine, - Entity, - AssetType, - LoadItem, -} from "@galacean/engine"; -import { SpineRenderer } from "@galacean/engine-spine"; -import * as dat from "dat.gui"; - -Logger.enable(); - -const gui = new dat.GUI(); - -// Create engine -WebGLEngine.create({ canvas: "canvas" }).then((engine) => { - engine.canvas.resizeByClientSize(); - - const scene = engine.sceneManager.activeScene; - const rootEntity = scene.createRootEntity(); - - // camera - const cameraEntity = rootEntity.createChild("camera_node"); - cameraEntity.addComponent(Camera); - cameraEntity.transform.position = new Vector3(0, 0, 60); - - const resource = generateSkinResource(); - const spineResource = { - urls: [ - "https://gw.alipayobjects.com/os/OasisHub/e675c9e1-2b19-4940-b8ed-474792e613d7/1629603245094.json", - "https://gw.alipayobjects.com/os/OasisHub/994dfadc-c498-4210-b9ba-0c3deed61fc5/1629603245095.atlas", - "https://gw.alipayobjects.com/zos/OasisHub/b52768b0-0374-4c64-a1bd-763b1a37ee5f/1629603245095.png", - ], - type: "spine", - }; - resource.unshift(spineResource); - engine.resourceManager.load(resource).then((res: any) => { - const spineResource = res[0]; - const spineEntity = rootEntity.createChild("spine"); - spineEntity.transform.setPosition(0, -12, 0); - rootEntity.addChild(spineEntity); - const spineRenderer = spineEntity.addComponent(SpineRenderer); - spineRenderer.resource = spineResource - spineRenderer.skinName = "skin1"; - spineRenderer.animationName = "02_walk"; - const { spineAnimation } = spineRenderer; - spineAnimation.scale = 0.07; - spineAnimation.addSeparateSlot("defult/head_hair"); - spineAnimation.addSeparateSlot("defult/arm_rigth_weapon"); - spineAnimation.addSeparateSlot("defult/Sleeveless_01"); - - const textures = []; - for (let i = 1; i < res.length; i += 1) { - textures.push(res[i]); - } - const info = { - 换头饰: "hair_0", - 换衣服: "clothes_0", - 换武器: "weapon_0", - }; - - const hatConfig = []; - const clothConfig = []; - const weaponConfig = []; - for (let i = 0; i < resource.length; i++) { - hatConfig.push(`hair_${i}`); - clothConfig.push(`clothes_${i}`); - weaponConfig.push(`weapon_${i}`); - } - gui.add(info, "换头饰", hatConfig).onChange((v) => { - changeSlotTexture(v, textures, spineAnimation); - }); - gui.add(info, "换衣服", clothConfig).onChange((v) => { - changeSlotTexture(v, textures, spineAnimation); - }); - gui.add(info, "换武器", weaponConfig).onChange((v) => { - changeSlotTexture(v, textures, spineAnimation); - }); - }); - - engine.run(); - - function generateSkinResource(): LoadItem[] { - const skinImgs = [ - "https://gw.alicdn.com/imgextra/i4/O1CN01NVzIQ61Hf7DT0jDWS_!!6000000000784-2-tps-802-256.png", - "https://gw.alicdn.com/imgextra/i3/O1CN01g3HnB21FPQPnjavP3_!!6000000000479-2-tps-802-256.png", - "https://gw.alicdn.com/imgextra/i1/O1CN01CvmDQl1gRFcWeh3Na_!!6000000004138-2-tps-802-256.png", - "https://gw.alicdn.com/imgextra/i3/O1CN01BviZcq1Rc2iTh127L_!!6000000002131-2-tps-802-256.png", - "https://gw.alicdn.com/imgextra/i3/O1CN01mkkLpR1ihrDHyYr1H_!!6000000004445-2-tps-802-256.png", - "https://gw.alicdn.com/imgextra/i2/O1CN019ENsCO2992jTG9RGD_!!6000000008024-2-tps-802-256.png", - "https://gw.alicdn.com/imgextra/i4/O1CN01fzyJFg1cNoBGRLSCI_!!6000000003589-2-tps-802-256.png", - "https://gw.alicdn.com/imgextra/i4/O1CN01duImZL1J8iQk2YzEj_!!6000000000984-2-tps-802-256.png", - "https://gw.alicdn.com/imgextra/i2/O1CN01b23DDj1QD1SoNL7ua_!!6000000001941-2-tps-802-256.png", - "https://gw.alicdn.com/imgextra/i1/O1CN01powK3y29HHrZCBnbg_!!6000000008042-2-tps-802-256.png", - "https://gw.alicdn.com/imgextra/i1/O1CN01n7R3dE1IRfCVUgvhE_!!6000000000890-2-tps-802-256.png", - "https://gw.alicdn.com/imgextra/i3/O1CN01t0nsyV24AoBFhIfyZ_!!6000000007351-2-tps-802-256.png", - "https://gw.alicdn.com/imgextra/i4/O1CN01mYwBUD1eBYp2rE0qV_!!6000000003833-2-tps-802-256.png", - "https://gw.alicdn.com/imgextra/i1/O1CN01ks7zZs1mbgKwBjlFS_!!6000000004973-2-tps-802-256.png", - "https://gw.alicdn.com/imgextra/i3/O1CN01mgFHl5262gO0L0JeR_!!6000000007604-2-tps-802-256.png", - "https://gw.alicdn.com/imgextra/i1/O1CN01SJbFkU1udWrRhXPbd_!!6000000006060-2-tps-802-256.png", - "https://gw.alicdn.com/imgextra/i3/O1CN01VGL8pe26qbYegHClp_!!6000000007713-2-tps-802-256.png", - "https://gw.alicdn.com/imgextra/i2/O1CN01EeZs6N1auCy4QbXiY_!!6000000003389-2-tps-802-256.png", - "https://gw.alicdn.com/imgextra/i3/O1CN01DOfF5J1UTkOMHSnwV_!!6000000002519-2-tps-802-256.png", - "https://gw.alicdn.com/imgextra/i1/O1CN01iWGD1h1G0ytSTLs67_!!6000000000561-2-tps-802-256.png", - "https://gw.alicdn.com/imgextra/i1/O1CN01xjhSTG245JQVrtEhL_!!6000000007339-2-tps-802-256.png", - "https://gw.alicdn.com/imgextra/i3/O1CN01NJAp7c22RdV8PC1Dq_!!6000000007117-2-tps-802-256.png", - "https://gw.alicdn.com/imgextra/i1/O1CN01A2Mdh01INXdP46W6B_!!6000000000881-2-tps-802-256.png", - "https://gw.alicdn.com/imgextra/i3/O1CN01AqHn4524RIRMTuuNH_!!6000000007387-2-tps-802-256.png", - "https://gw.alicdn.com/imgextra/i4/O1CN01yU8Z771SPVUUS0Die_!!6000000002239-2-tps-802-256.png", - "https://gw.alicdn.com/imgextra/i3/O1CN01orLkIg1JOkIFur5Fj_!!6000000001019-2-tps-802-256.png", - "https://gw.alicdn.com/imgextra/i3/O1CN01jRRXrV1b4HgOXGqov_!!6000000003411-2-tps-802-256.png", - "https://gw.alicdn.com/imgextra/i2/O1CN01XOchrA1Mh0wFgddGl_!!6000000001465-2-tps-802-256.png", - "https://gw.alicdn.com/imgextra/i2/O1CN01zPPHrD1pIOVHtvDqD_!!6000000005337-2-tps-802-256.png", - ]; - return skinImgs.map((item) => { - return { - type: AssetType.Texture2D, - url: item, - }; - }); - } - - function changeSlotTexture(selectItem, textures, spineAnimation) { - const slotNameMap = { - hair: "defult/head_hair", - weapon: "defult/arm_rigth_weapon", - clothes: "defult/Sleeveless_01", - }; - const slotKey = selectItem.split("_")[0]; - const slotName = slotNameMap[slotKey]; - const index = selectItem.split("_")[1]; - spineAnimation.hackSeparateSlotTexture(slotName, textures[index]); - } -}); diff --git a/examples/spine-mix-and-match.ts b/examples/spine-mix-and-match.ts new file mode 100644 index 0000000000..e74cd308ba --- /dev/null +++ b/examples/spine-mix-and-match.ts @@ -0,0 +1,213 @@ +/** + * @title Spine Mix And Match + * @category 2D + * @thumbnail https://mdn.alipayobjects.com/merchant_appfe/afts/img/A*IALeTYOXMXwAAAAAAAAAAAAADiR2AQ/original + */ +import { Camera, Entity, Logger, Script, Vector3, WebGLEngine } from "@galacean/engine"; +import { Skin, SpineAnimationRenderer } from "@galacean/engine-spine"; +import * as dat from "dat.gui"; + +const gui = new dat.GUI(); + +Logger.enable(); + +// Create engine +WebGLEngine.create({ canvas: "canvas" }).then((engine) => { + engine.canvas.resizeByClientSize(); + + const scene = engine.sceneManager.activeScene; + const rootEntity = scene.createRootEntity(); + + // camera + const cameraEntity = rootEntity.createChild("camera_node"); + const camera = cameraEntity.addComponent(Camera); + cameraEntity.transform.position = new Vector3(0, 0, 100); + camera.nearClipPlane = 0.001; + camera.farClipPlane = 20000; + + engine.resourceManager + .load({ + url: "https://mdn.alipayobjects.com/huamei_kz4wfo/uri/file/as/2/kz4wfo/4/mp/24ejL92gvbWxsXRi/mix-and-match/mix-and-match.json", + type: "spine", + }) + .then((spineResource: any) => { + const spineEntity = new Entity(engine); + spineEntity.transform.setPosition(0, -18, 0); + const spine = spineEntity.addComponent(SpineAnimationRenderer); + spine.resource = spineResource; + spine.defaultState.scale = 0.05; + spine.defaultState.skinName = 'full-skins/girl'; + spine.defaultState.animationName = 'idle'; + const mixAndMatch = spineEntity.addComponent(MixAndMatch); + rootEntity.addChild(spineEntity); + const { state } = spine; + const info = { + Eyes: mixAndMatch.eyesSkins[0], + Hair: mixAndMatch.hairSkins[0], + Nose: mixAndMatch.noseSkins[0], + Bag: mixAndMatch.bagSkins[0], + Hat: mixAndMatch.hatSkins[0], + Pants: mixAndMatch.pantsSkins[0], + Cloth: mixAndMatch.clothesSkins[0], + }; + gui + .add(info, "Eyes", mixAndMatch.eyesSkins) + .onChange((eyesSkinName) => { + mixAndMatch.eyesSkin = eyesSkinName; + mixAndMatch.updateCharacterSkin(); + mixAndMatch.updateCombinedSkin(); + state.setAnimation(1, 'blink', false); + state.addEmptyAnimation(1, 0.3, 0); + }); + + gui + .add(info, "Hair", mixAndMatch.hairSkins) + .onChange((hairSkinName) => { + mixAndMatch.hairSkin = hairSkinName; + mixAndMatch.updateCharacterSkin(); + mixAndMatch.updateCombinedSkin(); + state.data.defaultMix = 0.2; + state.setAnimation(0, 'aware', false); + state.addAnimation(0, 'idle', true, 0); + }); + + gui + .add(info, "Nose", mixAndMatch.noseSkins) + .onChange((noseSkinName) => { + mixAndMatch.noseSkin = noseSkinName; + mixAndMatch.updateCharacterSkin(); + mixAndMatch.updateCombinedSkin(); + state.data.defaultMix = 0.2; + state.setAnimation(1, 'blink', false); + state.addEmptyAnimation(1, 0.3, 0); + }); + + gui + .add(info, "Bag", mixAndMatch.bagSkins) + .onChange((bagSkinName) => { + mixAndMatch.bagSkin = bagSkinName; + mixAndMatch.updateCharacterSkin(); + mixAndMatch.updateCombinedSkin(); + state.data.defaultMix = 0.3; + if (bagSkinName) { + state.setAnimation(0, 'dance', true); + state.addAnimation(0, 'idle', true, 1); + } + }); + + gui + .add(info, "Hat", mixAndMatch.hatSkins) + .onChange((hatSkinName) => { + mixAndMatch.hatSkin = hatSkinName; + mixAndMatch.updateCharacterSkin(); + mixAndMatch.updateCombinedSkin(); + state.data.defaultMix = 0.2; + state.setAnimation(0, 'aware', false); + state.addAnimation(0, 'idle', true, 0); + }); + + gui + .add(info, "Pants", mixAndMatch.pantsSkins) + .onChange((pantsSkinName) => { + mixAndMatch.pantsSkin = pantsSkinName; + mixAndMatch.updateCharacterSkin(); + mixAndMatch.updateCombinedSkin(); + state.data.defaultMix = 0.2; + state.setAnimation(0, 'dress-up', false); + state.addAnimation(0, 'idle', true, 0); + }); + + gui + .add(info, "Cloth", mixAndMatch.clothesSkins) + .onChange((clothSkinName) => { + mixAndMatch.clothesSkin = clothSkinName; + mixAndMatch.updateCharacterSkin(); + mixAndMatch.updateCombinedSkin(); + state.data.defaultMix = 0.2; + state.setAnimation(0, 'dress-up', false); + state.addAnimation(0, 'idle', true, 0); + }); + + }); + + engine.run(); +}); + +enum ItemType { + Cloth, + Pants, + Bag, + Hat +} + +class MixAndMatch extends Script { + noseSkin = "nose/short"; + eyesSkin = "eyes/violet"; + hairSkin = "hair/brown"; + bagSkin = ""; + baseSkin = "skin-base"; + eyelidsSkin = 'eyelids/girly'; + clothesSkin = "clothes/hoodie-orange"; + pantsSkin = "legs/pants-jeans"; + hatSkin = "accessories/hat-red-yellow"; + spine: SpineAnimationRenderer; + characterSkin: Skin; + + eyesSkins = ["eyes/violet", "eyes/green", "eyes/yellow"]; + hairSkins = ["hair/brown", "hair/blue", "hair/pink", "hair/short-red", "hair/long-blue-with-scarf"]; + noseSkins = ["nose/short", "nose/long"]; + bagSkins = ["", "accessories/bag", "accessories/backpack"]; + hatSkins = ["accessories/hat-red-yellow", "accessories/hat-pointy-blue-yellow"]; + pantsSkins = ["legs/pants-jeans", "legs/pants-green"]; + clothesSkins = ["clothes/hoodie-orange", "clothes/dress-blue", "clothes/dress-green", "clothes/hoodie-blue-and-scarf"]; + + onAwake(): void { + this.spine = this.entity.getComponent(SpineAnimationRenderer)!; + } + + onStart(): void { + this.updateCharacterSkin(); + this.updateCombinedSkin(); + } + + updateCharacterSkin() { + const skeletonAnimation = this.spine; + const skeleton = skeletonAnimation.skeleton; + const skeletonData = skeleton.data; + const skin = new Skin("character-base"); + skin.addSkin(skeletonData.findSkin(this.baseSkin)!); + skin.addSkin(skeletonData.findSkin(this.noseSkin)!); + skin.addSkin(skeletonData.findSkin(this.eyelidsSkin)!); + skin.addSkin(skeletonData.findSkin(this.eyesSkin)!); + skin.addSkin(skeletonData.findSkin(this.hairSkin)!); + this.characterSkin = skin; + } + + updateCombinedSkin() { + const skeletonAnimation = this.spine; + const skeleton = skeletonAnimation.skeleton; + const resultCombinedSkin = new Skin("character-combined"); + + resultCombinedSkin.addSkin(this.characterSkin); + this.addEquipmentSkinsTo(resultCombinedSkin); + + skeleton.setSkin(resultCombinedSkin); + skeleton.setSlotsToSetupPose(); + } + + addEquipmentSkinsTo(combinedSkin: Skin) { + const skeleton = this.spine.skeleton; + const skeletonData = skeleton.data; + combinedSkin.addSkin(skeletonData.findSkin(this.clothesSkin)!); + combinedSkin.addSkin(skeletonData.findSkin(this.pantsSkin)!); + if (this.bagSkin) { + combinedSkin.addSkin(skeletonData.findSkin(this.bagSkin)!); + } + if (this.hatSkin) { + combinedSkin.addSkin(skeletonData.findSkin(this.hatSkin)!); + } + } +} + + + diff --git a/examples/spine-performance.ts b/examples/spine-performance.ts index f1a61c4591..36ae289188 100644 --- a/examples/spine-performance.ts +++ b/examples/spine-performance.ts @@ -3,8 +3,8 @@ * @category Benchmark * @thumbnail https://mdn.alipayobjects.com/merchant_appfe/afts/img/A*6xrGR6nr1c0AAAAAAAAAAAAADiR2AQ/original */ -import { Camera, Vector3, WebGLEngine } from "@galacean/engine"; -import { SpineRenderer } from "@galacean/engine-spine"; +import { Camera, Entity, Vector3, WebGLEngine } from "@galacean/engine"; +import { SpineAnimationRenderer } from "@galacean/engine-spine"; import { Stats } from "@galacean/engine-toolkit-stats"; WebGLEngine.create({ canvas: "canvas" }).then((engine) => { @@ -21,19 +21,15 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { engine.resourceManager .load({ - urls: [ - "https://gw.alipayobjects.com/os/OasisHub/a66ef194-6bc8-4325-9a59-6ea9097225b1/1620888427489.json", - "https://gw.alipayobjects.com/os/OasisHub/a1e3e67b-a783-4832-ba1b-37a95bd55291/1620888427490.atlas", - "https://gw.alipayobjects.com/zos/OasisHub/a3ca8f62-1068-43a5-bb64-5c9a0f823dde/1620888427490.png", - ], + url: "https://mdn.alipayobjects.com/huamei_kz4wfo/uri/file/as/2/kz4wfo/4/mp/qGISZ7QTJFkEL0Qx/spineboy/spineboy.json", type: "spine", }) - .then((spineResouce: any) => { - const spineEntity = rootEntity.createChild("spine"); - const spineRenderer = spineEntity.addComponent(SpineRenderer); - spineRenderer.resource = spineResouce; - spineRenderer.scale = 0.01; - spineRenderer.animationName = "walk"; + .then((resource: any) => { + const spineEntity = new Entity(engine); + const spine = spineEntity.addComponent(SpineAnimationRenderer); + spine.resource = resource; + spine.defaultState.scale = 0.02; + spine.defaultState.animationName = "walk"; for (let i = -5; i < 5; i++) { for (let j = -5; j < 5; j++) { const clone = spineEntity.clone(); diff --git a/examples/spine-physics.ts b/examples/spine-physics.ts new file mode 100644 index 0000000000..f88727a53c --- /dev/null +++ b/examples/spine-physics.ts @@ -0,0 +1,63 @@ +/** + * @title Spine Physics + * @category 2D + * @thumbnail https://mdn.alipayobjects.com/merchant_appfe/afts/img/A*IALeTYOXMXwAAAAAAAAAAAAADiR2AQ/original + */ +import { Camera, Entity, Logger, Script, Vector3, WebGLEngine } from "@galacean/engine"; +import { SpineAnimationRenderer } from "@galacean/engine-spine"; + +Logger.enable(); + +// Create engine +WebGLEngine.create({ canvas: "canvas" }).then((engine) => { + engine.canvas.resizeByClientSize(); + + const scene = engine.sceneManager.activeScene; + const rootEntity = scene.createRootEntity(); + + // camera + const cameraEntity = rootEntity.createChild("camera_node"); + const camera = cameraEntity.addComponent(Camera); + cameraEntity.transform.position = new Vector3(0, 0, 2000); + camera.nearClipPlane = 0.001; + camera.farClipPlane = 20000; + + engine.resourceManager + .load({ + urls: [ + "https://mdn.alipayobjects.com/portal_h1wdez/afts/file/A*Po6oQJyLdb0AAAAAAAAAAAAAAQAAAQ?a=.json", + "https://mdn.alipayobjects.com/portal_h1wdez/afts/file/A*CnqHS5nRzTIAAAAAAAAAAAAAAQAAAQ?b=.atlas", + "https://mdn.alipayobjects.com/portal_h1wdez/afts/img/A*WDXeRIpd-lAAAAAAAAAAAAAAAQAAAQ/original?c=.png" + ], + type: "spine", + }) + .then((spineResource: any) => { + const spineEntity = new Entity(engine); + spineEntity.transform.setPosition(0, -250, 0); + const spine = spineEntity.addComponent(SpineAnimationRenderer); + spine.resource = spineResource; + spine.defaultState.animationName = 'wind-idle'; + spine.defaultState.scale = 0.5; + rootEntity.addChild(spineEntity); + const { skeleton } = spine; + spineEntity.addComponent(class extends Script { + private _vec3 = new Vector3(); + onUpdate(): void { + const { inputManager } = engine; + const pointers = inputManager.pointers; + if (pointers.length > 0) { + const { position } = pointers[0]; + const worldPos = this._vec3; + camera.screenToWorldPoint( + new Vector3(position.x, position.y, 2000), + worldPos, + ); + skeleton.y = worldPos.y - 480; + skeleton.x = worldPos.x; + } + } + }); + }); + + engine.run(); +}); diff --git a/examples/spine-skin-change.ts b/examples/spine-skin-change.ts deleted file mode 100644 index e071c0ed9e..0000000000 --- a/examples/spine-skin-change.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * @title Spine Change Skin - * @category 2D - * @thumbnail https://mdn.alipayobjects.com/merchant_appfe/afts/img/A*6RVDRrZkOlgAAAAAAAAAAAAADiR2AQ/original - */ -import { Camera, Logger, Vector3, WebGLEngine, Entity } from "@galacean/engine"; -import { SpineRenderer } from "@galacean/engine-spine"; -import * as dat from "dat.gui"; - -Logger.enable(); - -const gui = new dat.GUI(); - -// Create engine -WebGLEngine.create({ canvas: "canvas" }).then((engine) => { - engine.canvas.resizeByClientSize(); - - const scene = engine.sceneManager.activeScene; - const rootEntity = scene.createRootEntity(); - - // camera - const cameraEntity = rootEntity.createChild("camera_node"); - const camera = cameraEntity.addComponent(Camera); - cameraEntity.transform.position = new Vector3(0, 0, 60); - - engine.resourceManager - .load({ - urls: [ - "https://gw.alipayobjects.com/os/OasisHub/c51a45ef-f248-4835-b601-6d31a901f298/1629713824525.json", - "https://gw.alipayobjects.com/os/OasisHub/b016738d-173a-4506-9112-045ebba84d82/1629713824527.atlas", - "https://gw.alipayobjects.com/zos/OasisHub/747a94f3-8734-47b3-92b3-2d7fe2d36e58/1629713824527.png", - ], - type: "spine", - }) - .then((spineResource: any) => { - const spineEntity = rootEntity.createChild("spine"); - rootEntity.addChild(spineEntity); - const spineRenderer = spineEntity.addComponent(SpineRenderer); - spineRenderer.resource = spineResource; - const spineAnimation = spineRenderer.spineAnimation; - const { skeleton, state } = spineAnimation; - spineEntity.transform.setPosition(0, -18, 0); - state.setAnimation(0, "dance", true); - skeleton.setSkinByName("girl"); // 1. Set the active skin - skeleton.setSlotsToSetupPose(); // 2. Use setup pose to set base attachments. - state.apply(skeleton); - spineAnimation.scale = 0.05; - const info = { - skin: "girl", - }; - gui - .add(info, "skin", [ - "girl", - "girl-blue-cape", - "girl-spring-dress", - "boy", - ]) - .onChange((skinName) => { - skeleton.setSkinByName(skinName); // 1. Set the active skin - skeleton.setSlotsToSetupPose(); // 2. Use setup pose to set base attachments. - state.apply(skeleton); - }); - }); - - engine.run(); -}); From 0d1d5210c48d38e594485ed80148827a445ea8bf Mon Sep 17 00:00:00 2001 From: johanzhu Date: Thu, 25 Jul 2024 11:21:56 +0800 Subject: [PATCH 2/4] feat: add spine doc --- docs/zh/graphics/2D/2d.md | 2 +- docs/zh/graphics/2D/spine.md | 476 -------------------------- docs/zh/graphics/2D/spine/editor.md | 133 +++++++ docs/zh/graphics/2D/spine/example.md | 41 +++ docs/zh/graphics/2D/spine/other.md | 32 ++ docs/zh/graphics/2D/spine/overview.md | 24 ++ docs/zh/graphics/2D/spine/runtime.md | 382 +++++++++++++++++++++ 7 files changed, 613 insertions(+), 477 deletions(-) delete mode 100644 docs/zh/graphics/2D/spine.md create mode 100644 docs/zh/graphics/2D/spine/editor.md create mode 100644 docs/zh/graphics/2D/spine/example.md create mode 100644 docs/zh/graphics/2D/spine/other.md create mode 100644 docs/zh/graphics/2D/spine/overview.md create mode 100644 docs/zh/graphics/2D/spine/runtime.md diff --git a/docs/zh/graphics/2D/2d.md b/docs/zh/graphics/2D/2d.md index 478559d360..b64a47abb0 100644 --- a/docs/zh/graphics/2D/2d.md +++ b/docs/zh/graphics/2D/2d.md @@ -25,4 +25,4 @@ Galacean 是 3D/2D 的互动解决方案,您可以在**编辑器主页**的** - [文字渲染器](/docs/graphics-2d-text) - [精灵图集](/docs/graphics-2d-spriteAtlas) - [Lottie](/docs/graphics-2d-lottie) -- [Spine](/docs/graphics-2d-spine) +- [Spine](/docs/graphics/2D/spine/overview) diff --git a/docs/zh/graphics/2D/spine.md b/docs/zh/graphics/2D/spine.md deleted file mode 100644 index 4d1adbd4d2..0000000000 --- a/docs/zh/graphics/2D/spine.md +++ /dev/null @@ -1,476 +0,0 @@ ---- -order: 7 -title: Spine -type: 图形 -group: 2D -label: Graphics/2D ---- - -Spine 动画是一款针对游戏开发的 2D 骨骼动画,它通过将图片绑定到骨骼上,然后再控制骨骼实现动画,它可以满足程序对动画的控制与自由度,同时也为美术与设计提供了更高效和简洁的工作流。 -相较于传统的帧动画,Spine 动画更具优势: - -- **更小的体积:** 传统的动画需要提供每一帧图片。而 Spine 动画只保存骨骼的动画数据,它所占用的空间非常小。 -- **美术需求:** Spine 动画需要的美术资源更少,能为您节省出更多的人力物力更好的投入到游戏开发中去。 -- **流畅性:** Spine 动画使用差值算法计算中间帧,这能让你的动画总是保持流畅的效果。 -- **装备附件:** 图片绑定在骨骼上来实现动画。如果你需要可以方便的更换角色的装备满足不同的需求。甚至改变角色的样貌来达到动画重用的效果。 -- **混合:** 动画之间可以进行混合。比如一个角色可以开枪射击,同时也可以走、跑、跳或者游泳。 -- **程序动画:** 可以通过代码控制骨骼,比如可以实现跟随鼠标的射击,注视敌人,或者上坡时的身体前倾等效果。 - -# 在 Galacean 编辑器中使用 Spine 动画 -Galacean 编辑器内置了对 Spine 动画的支持,无需额外下载或配置,开箱即用,大大简化了开发流程。 -## 资产管理 -### 从 Spine 编辑器导出资产 -你可以在《Spine用户指南》中找到完整的步骤, 说明如何来: - -1. [导出 skeleton 和 animation 数据](https://zh.esotericsoftware.com/spine-export) -2. [导出包含 skeleton 图像的 texture atlases](https://zh.esotericsoftware.com/spine-texture-packer) - -下面展示了 spine 导出资产的一个简要流程: - -1. 完成动画制作后,单击 `Spine 菜单`>`导出` ,打开导出窗口 - - - -2. 选择导出窗口左上角的**二进制** ( 推荐使用二进制,以二进制格式而不是JSON格式导出,会使文件体积更小,加载更快 - - - -3. 勾选上,**纹理图集**的打包复选框 - - - -4. 点击 **打包设置** - -这里建议勾选 **2 的幂数;预乘和溢出两项请勿勾选** -完成打包设置后,点击**确定** - - -5. 回到导出窗口,选择导出文件夹后,点击**导出** - - - -6. 将会得到三个如下文件: - - - -spineboy.skel 包含了 skeleton animation 数据,spineboy.atlas 包含了 texture atlas 信息,导出的图片可能有多张,每张图片都代表了 texture altas 中的一页 - -### 在 Galacean 编辑器中导入资产 -打开编辑器后,将导出的文件直接拖入到[资产面板](https://antg.antgroup.com/engine/docs/latest/cn/assets-interface)中,完成上传 - - - -也可以点击资产面板的上传按钮进行上传: - - - -上传完成后,在资产面板中能够看到上传的 spine 素材 - - -#### SpineSkeletonData 资产 - - - -SpineSkeletonData 资产存储了 skeleton 数据,以及对生成的 SpineAtlas 资产的引用 -点击资产后,能够在检查器中预览 Spine 动画,预览面板中能够切换皮肤和动画片段: - - - -#### SpineAtlas 资产 - - - -SpineAtlas 资产存储了texture atlas 文件,并包含了其对所需 Texture 资产的引用。 -点击资产后,能够在检查器中查看其引用的 Texture 资产,以及 Spine 的图集信息 - - - -### 资产更新 -如若需要更新你的 Spine 资产,直接覆盖这些文件即可完成更新。从 Spine 编辑器中重新导出资产,并再次导入到 Galacean 编辑器中覆盖原有文件即可。 - - -## 使用 Spine 组件 -### 添加组件 -完成资产上传后,可以通过添加 Spine 组件,将 Spine 动画添加到场景中。一共有三种方式添加: - -1. 拖入添加 - -拖入添加是最快捷的一种方式。点击 SpineSkeletonData 资产,按住后拖动到视图区,就能快速创建一个添加了 Spine 组件的实体,并指定资产为刚刚选中的资产。 - - - -2. 快速添加 - -点击左上角的快速添加按钮,选择 `2D Object`>`SpineAnimationRenderer`, - - - -添加完成后,能够看到一个新的实体,挂载了 Spine 组件;点击 Resource 属性,选择上传的 SpineSkeletonData 资产,就能看到 Spine 动画啦 - - - -3. 手动添加 - -手动添加的方式与快速添加类似,不过需要创建一个新的实体,并通过检查器的 AddComponent 按钮添加 Spine 组件 - - - -添加了 Spine 组件后,同样需要指定组件的 Resource,也就是 Spine 组件要渲染的 SpineSkeletonData 资产。 - -### 组件配置 -点击实体,能够在检查器中看到 Spine 组件的配置项。![image.png](https://intranetproxy.alipay.com/skylark/lark/0/2024/png/76063/1721269455151-ceaf0b7e-a00b-4ba4-a109-0cdb08c135f2.png#clientId=uf07edf19-371c-4&from=paste&height=199&id=ucac2f7de&originHeight=398&originWidth=1060&originalType=binary&ratio=2&rotation=0&showTitle=false&size=82934&status=done&style=shadow&taskId=ub50cded7-5559-41de-b104-9db3c4bb4f5&title=&width=530) -通过 Spine 组件能够配置 Spine 动画的默认状态。各属性含义如下: - -- Resource:Spine 动画的资源,即 SpineSkeletonData 资产 -- Animation:默认播放的动画名称 -- Loop:默认播放的动画是否循环 -- Skin:默认的皮肤名称 -- Scale:默认的缩放系数 -- Priority:渲染优先级 -### 组件 API -除了在编辑器中配置默认状态外,在[脚本](https://antg.antgroup.com/engine/docs/latest/cn/script)中能够更加灵活的操作。Spine 组件暴露了两个重要的 API 来进行动画控制与骨架操作,以实现更加复杂的效果。 -#### 动画控制 AnimationState -在脚本中,你能够通过以下方式获取到 [AnimationState](https://zh.esotericsoftware.com/spine-api-reference#AnimationState) 对象,来控制 Spine 的动画播放逻辑。 -```typescript -class YourAmazingScript { - - onStart() { - const spine = this.entity.getComponent(SpineAnimationRenderer); - const { state } = spine; // AnimationState 对象 - } - -} -``` -##### 播放动画 -首先,我们来介绍一下最常用的 API:[setAnimation](https://zh.esotericsoftware.com/spine-api-reference#AnimationState-setAnimation) -```typescript -state.setAnimation(0, 'animationName', true) -``` -setAnimation 函数接受三个参数: - -- TrackIndex:动画轨道序号 -- animationName:动画名称 -- loop:是否循环播放 - -后两个参数很好理解,第一个参数则包含了 Spine 动画的一个概念:**Track** (轨道) -> Spine 动画在播放时,需要指定一个动画轨道。借助动画轨道,Spine 能够分层应用动画,每一个轨道都能够存储动画与播放参数,轨道的编号从 0 开始累加。在动画应用后,Spine 会从低轨道到高轨道依次应用动画,高轨道上的动画将会覆盖低轨道上的动画。 -动画轨道有很多用途,例如,轨道 0 可以有行走、奔跑、游泳或其他动画,轨道 1 可以有一个只为手臂和开枪设置了关键帧的射击动画。此外,为高层轨道设置TrackEntry alpha可使其与下面的轨道混合。例如,轨道 0 可以有一个行走动画,轨道 1 可以有一个跛行动画。当玩家受伤时,增加轨道 1 的alpha值,跛行就会加重。 - - -##### 设置过渡 -调用 setAnimation 方法后,会立即切换当前轨道的动画。如果你需要动画切换时有过渡效果,就需要设置过渡的持续时间了。这时就需要 [AnimationStateData](https://zh.esotericsoftware.com/spine-api-reference#AnimationStateData) 的 API 来进行设置了: -```typescript -class YourAmazingScript { - - onStart() { - const spine = this.entity.getComponent(SpineAnimationRenderer); - const { state } = spine; // AnimationState 对象 - const { data } = state; // AnimationStateData 对象 - data.defaultMix = 0.2; - data.setMix('animationA', 'animationB', 0.3); - } - -} -``` - -- defaultMix 是当两个动画间没有定义混合持续时间时的默认持续时间 -- setMix 函数接受三个参数,前两个是需要设置过渡时间的动画名称,第三个则是动画混合的持续时间 -##### 动画队列 -Spine 还提供了 [addAnimation](https://zh.esotericsoftware.com/spine-api-reference#AnimationState-addAnimation2) 方法来实现动画的队列播放: -```typescript -state.setAnimation(0, 'animationA', false); -state.addAnimation(0, 'animationB', true, 0); -``` -addAnimation 接受 4 个参数: - -- TrackIndex:动画轨道 -- animationName:动画名称 -- loop:是否循环播放 -- delay:延迟时间 - -前三个参数很好理解,这里解释一下第四个参数: -delay 代表了前一个动画的持续时间。 -当 delay > 0 时(假设 delay 为 1),前一个动画会在播放 1 秒后,切换到下一个动画。如下图所示: - - - -如果动画 A 的时长小于 1 秒,则会根据是否设置了循环播放:循环播放直至 1 秒,或者播放完毕后,保持在动画播放完毕的状态直至 1 秒。 -当 delay = 0 时,下一个动画会在前一个动画播放完毕后播放,如下图所示: - - - -假设动画 A 的时长为 1 秒,过渡持续时间为 0.2 秒,当 delay 设置为 0 时,动画 B 会从 1 - 0.2 也就是 0.8 秒开始过渡到动画 B。 -当 delay < 0 时,上一个动画未播放完毕前,下一个动画就会开始播放,如下图所示: -同样假设动画 A 的时长为 1 秒,过渡持续时间为 0.2 秒,动画 B 则会从 0.6 秒开始过渡到动画 B。 - - - -除了 addAnimation 外,还能够通过 addEmptyAnimation 方法添加空动画。空动画能够让动画回到初始状态。addEmptyAnimation 接受三个参数:TrackIndex,mixDuration 和 delay。TrackIndex 和 delay 参数与 addAnimation 一样。 mixDuration 是过渡持续时间,动画会逐渐回到初始状态。如下图所示(右侧棕色区域即是空动画), - - - -##### 轨道参数 -setAnimation 和 addAnimation 方法都会返回一个对象:TrackEntry。TrackEntry 提供了更多的参数来进行动画控制。 -例如: - -- timeScale:控制动画播放的速度 -- animationStart:控制动画播放的开始时间 -- apha:当前动画应用轨道的混合系数 - -更多参数可以参考 [TrackEntry 官方文档](https://zh.esotericsoftware.com/spine-api-reference#TrackEntry) -##### 动画事件 - - - -当调用 AnimationState API 进行动画控制时,会触发如上图所示的事件。在新的动画开始播放时,会触发 Start 事件,当动画在动画队列中移除或者中断时,会触发 End 事件。当动画播放完毕时,无论是否循环,都会触发 Complete 事件。 -全部的事件以及详细解释请参考:[Spine 动画事件官方文档](https://zh.esotericsoftware.com/spine-unity-events) -这些事件能够通过 [AnimationState.addListener](https://zh.esotericsoftware.com/spine-api-reference#AnimationState-addListener) 进行监听。 -```typescript -class YourAmazingScript { - - onStart() { - const spine = this.entity.getComponent(SpineAnimationRenderer); - const { state } = spine; // AnimationState 对象 - state.addListener({ - start: (entry: TrackEntry) => { - // call back function - }, - complete: (entry: TrackEntry) => { - // call back function - }, - // 还有 end,interrupt,dispose,event 等事件 - // end, interrupt, dispose, event - }) - } - -} -``` - -#### 骨架操作 Skeleton -在脚本中,你能够通过以下方式获取到 [Skeleton](https://zh.esotericsoftware.com/spine-api-reference#Skeleton) 对象,来访问骨骼、插槽、附件等等,并进行各类操作。 -```typescript -class YourAmazingScript { - - onStart() { - const spine = this.entity.getComponent(SpineAnimationRenderer); - const { skeleton } = spine; // Skeleton 对象 - } - -} -``` -下面是一些常用的操作。 -##### 修改骨骼位置 -通过 Skeleton API 能够修改 Spine 骨骼的位置,比较常见的应用是:可以通过设置 IK 的目标骨骼,来实现瞄准/跟随效果。 -```typescript -class YourAmazingScript { - - onStart() { - const spine = this.entity.getComponent(SpineAnimationRenderer); - const { skeleton } = spine; // Skeleton 对象 - const bone = skeleton.findBone('aim-target'); - bone.x = targetX; - bone.y = targetY; - } - -} -``` -‼️ 由于应用动画会修改骨骼位置,所以如果 Spine 在播放动画, 那么骨骼位置的修改需要在应用动画之后,也就是在脚本的 onLateUpdate 生命周期中进行操作。 - -##### 附件更换 -通过 Skeleton API 能够替换[插槽](https://zh.esotericsoftware.com/spine-slots)内的[附件](https://zh.esotericsoftware.com/spine-attachments)。通过切换附件,能够实现局部换装的效果。 -```typescript -class YourAmazingScript { - - onStart() { - const spine = this.entity.getComponent(SpineAnimationRenderer); - const { skeleton } = spine; // Skeleton 对象 - // 根据名称查找插槽 - const slot = skeleton.findSlot('slotName'); - // 按名称从骨架皮肤或默认皮肤获取附件 - const attachment = skeleton.getAttachment(slot.index, 'attachmentName'); - // 设置插槽附件 - slot.attachment = attachment; - // 或者由骨架setAttachment方法来设置插槽附件 - skeleton.setAttachment('slotName', 'attachmentName'); - } -} -``` -‼️ 由于应用动画会修改插槽内的附件,所以如果 Spine 在播放动画,那么附件更换的操作需要在应用动画之后,也就是在脚本的 onLateUpdate 生命周期中进行操作。 -##### 换肤与混搭 -**换肤** -通过 Skeleton 的 [setSkin](https://zh.esotericsoftware.com/spine-api-reference#Skeleton-setSkin) API 能够根据皮肤名称实现整体换肤。 -```typescript -class YourAmazingScript { - - onStart() { - const spine = this.entity.getComponent(SpineAnimationRenderer); - const { skeleton } = spine; // Skeleton 对象 - // 根据皮肤名称设置皮肤 - skeleton.setSkinByName("full-skins/girl"); - // 回到初始位置(必须调用,否则渲染可能出现错乱) - skeleton.setSlotsToSetupPose(); - } - -} -``` -**混搭** -在 Spine 编辑器中,设计师可以为每一个外观和装备准备皮肤,然后在运行时把他们组合成一个新的皮肤。下面的代码展示了如果通过 addSkin 来添加选定的皮肤的: -```typescript -class YourAmazingScript { - - onStart() { - const spine = this.entity.getComponent(SpineAnimationRenderer); - const { skeleton } = spine; // Skeleton 对象 - const mixAndMatchSkin = new spine.Skin("custom-girl"); - mixAndMatchSkin.addSkin(skeletonData.findSkin("skin-base")); - mixAndMatchSkin.addSkin(skeletonData.findSkin("nose/short")); - mixAndMatchSkin.addSkin(skeletonData.findSkin("eyelids/girly")); - mixAndMatchSkin.addSkin(skeletonData.findSkin("eyes/violet")); - mixAndMatchSkin.addSkin(skeletonData.findSkin("hair/brown")); - mixAndMatchSkin.addSkin(skeletonData.findSkin("clothes/hoodie-orange")); - mixAndMatchSkin.addSkin(skeletonData.findSkin("legs/pants-jeans")); - mixAndMatchSkin.addSkin(skeletonData.findSkin("accessories/bag")); - mixAndMatchSkin.addSkin(skeletonData.findSkin("accessories/hat-red-yellow")); - this.skeleton.setSkin(mixAndMatchSkin); - } - -} -``` -代码中皮肤的名称来自于官方的 mix-and-match 示例,该示例可以在下面的章节看到。 - -# 在代码中使用 Galacean Spine 运行时 -无论是通过编辑器下载的项目,或者是 procode 项目,都需要通过安装 @galacean/engine-spine 来注册 Spine 组件以实现 Spine 动画的加载和渲染。 -## 安装 -```typescript -npm install @galacean/engine-spine --save -``` - -## 加载 spine 资产 -引入 @galacean/engine-spine 后,就能通过引擎的 resourceManager 加载 Spine 资产,并创建 Spine 动画了。Galacean Spine 加载器支持多方式加载 Spine 资产: -### 加载 Galacean 编辑器中的上传的资产 -[导出编辑器项目后](https://antg.antgroup.com/engine/docs/latest/cn/assets-build),已添加至场景中的 Spine 动画,会在项目运行后自动加载。 -如需动态加载 Spine 动画可以按照如下步骤,在代码中进行加载: - -1. 找到 Spine 动画的资产链接 - - - -点击 Galacean 编辑器的下载按钮,选择 project URL,拷贝 project.json 后打开,找到上传的 spine 动画文件(skel / json): - - - -2. 使用 resourceManager 加载 -```typescript -import { SpineAnimationRenderer } from '@galacean/engine-spine'; - -// 初始化 galacean - -// 加载 spine 资产 -const resource = await engine.resourceManager.load( - { - url: 'https://galacean.spineboy.json', // 编辑器资产 - type: 'spine', - }, -); -// 创建实体 -const spineEntity = new Entity(engine); -// 添加 spine 组件 -const spine = spineEntity.addComponent(SpineAnimationRenderer); -// 设置默认动画 -spine.defaultState.animatitonName = 'your-default-animation'; -// 添加至场景 -root.addChild(spineEntity); - -``` -### 加载自定义上传的资产 -如果你的 Spine 资产未通过 Galacean 编辑器进行上传,同样能够通过 Galacean Spine 加载器进行加载。 -```typescript -const resource = await engine.resourceManager.load( - { - url: 'https://your.spineboy.json', // 自定义上传的资产 - type: 'spine', - }, -); -``` - -- 当传递一个 url 地址时,需要确保 atlas 和 texture 资源在相同目录下,即: - -https://your.spineboy.atlas,https://your.spineboy.png 文件需要存在。 - -- 若资源不在相同目录下,还可以通过传递 urls 来进行加载: -```typescript -const resource = await engine.resourceManager.load( - { - urls: [ - 'https://your.spineboy.json', - 'https://ahother-path1.spineboy.altas', - 'https://ahother-path2.spineboy.png', - ], // 自定义上传的资产 - type: 'spine', - }, -); -``` -若不传递 texture 地址,那么加载器会从 atlas 文件中读区 texture 图片名称,并从 atlas 的相对路径下查找 texture 资源。 -若自定上传的资产没有文件后缀(比如 blob 协议的 URL),则可以通过给链接添加 URL query 参数,例如: -https://your.spineboyjson?q=.json,https://your.spineboyatlas?q=.atlas,或者添加 fileExtension 参数来指定资源后缀类型: -```typescript -const resource = await engine.resourceManager.load( - { - urls: [ - 'https://your.spineboyjson', - 'https://ahother-path1.spineboyatlas', - 'https://ahother-path2.spineboypng', - ], // 自定义上传的资产 - type: 'spine', - fileExtensions: [ - 'json', - 'atlas', - 'png', - ] - }, -); -``` - -# 示例 -动画控制 - - -跟踪射击 - - -局部换肤 - - -整体换肤 - - -皮肤混搭 - - -物理 - - -# Spine 版本 -@galacen/engine-spine 自 1.2 版本后开始支持 spine 4.x 版本。@galacen/engine-spine 包的 major version 和 minor version 与 spine 版本完全对应,版本对照如下: -
@galacean/engine-spine < 1.2 对应 spine version 3.8 -
@galacean/engine-spine 4.0 对应 spine version 4.0 -
@galacean/engine-spine 4.1 对应 spine version 4.1 -
@galacean/engine-spine 4.2 对应 spine version 4.2 -
..... -# 版本升级 -升级到编辑器 1.3 版本后。除了需要在编辑器的[项目设置](https://antg.antgroup.com/engine/docs/latest/cn/interface-menu#%E9%A1%B9%E7%9B%AE%E8%AE%BE%E7%BD%AE)中升级引擎版本外,由于导出 JSON 或者二进制的 Spine 编辑器版本需要与运行时版本[保持一致](https://zh.esotericsoftware.com/spine-versioning#%E5%90%8C%E6%AD%A5%E7%89%88%E6%9C%AC),所以编辑器升级到 1.3 后,还需要重新导出 4.2 版本的 Spine 资产并上传到编辑器,通过文件覆盖完成资产的更新。 - -# Spine 性能建议 -这里提供一些优化 spine 动画性能的方法: - -1. 使用二进制文件(.skel)的形式导出 skeleton,二进制文件的体积更小,加载更快。 -2. 建议将附件打包到尽可能少的atlas页中, 并根据绘制顺序将附件分组置入atlas页以防止多余的material切换. 请参考:[Spine 纹理打包:文件夹结构](https://zh.esotericsoftware.com/spine-texture-packer#%E6%96%87%E4%BB%B6%E5%A4%B9%E7%BB%93%E6%9E%84)了解如何在你的Spine atlas中编排 atlas 区域。 -3. 少用裁减功能。Spine 的裁减实现是通过动态裁减三角形实现的,性能开销很大。 -4. 尽可能少地使用atlas page textures。即,导出是贴图的数量尽可能控制在一张。 - - - - - - - diff --git a/docs/zh/graphics/2D/spine/editor.md b/docs/zh/graphics/2D/spine/editor.md new file mode 100644 index 0000000000..0312ebf5e0 --- /dev/null +++ b/docs/zh/graphics/2D/spine/editor.md @@ -0,0 +1,133 @@ +--- +order: 1 +title: 在编辑器中使用 +type: 图形 +group: Spine +label: Graphics/2D/Spine/editor +--- + +本章节为大家介绍如何在 Galacean 编辑器中使用 Spine 动画。 + +Galacean 编辑器内置了对 Spine 动画的支持,无需额外下载或配置,开箱即用,大大简化了开发流程。 + +## 1. 从 Spine 编辑器导出资产 +第一步,需要从 Spine 编辑器导出你的 Spine 动画素材,你可以在《Spine用户指南》中找到完整的步骤, 说明如何: + +1. [导出 skeleton 和 animation 数据](https://zh.esotericsoftware.com/spine-export) +2. [导出包含 skeleton 图像的 texture atlases](https://zh.esotericsoftware.com/spine-texture-packer) + +下面展示了 spine 导出资产的一个简要流程: + +1. 完成动画制作后,单击 `Spine 菜单`>`导出` ,打开导出窗口 + + + +2. 选择导出窗口左上角的**二进制** ( 推荐使用二进制,以二进制格式而不是JSON格式导出,会使文件体积更小,加载更快 + + + +3. 勾选上,**纹理图集**的打包复选框 + + + +4. 点击 **打包设置** + +这里建议勾选 `2 的幂数`;`预乘`和`溢出`两项请勿勾选 +完成打包设置后,点击**确定** + + +5. 回到导出窗口,选择导出文件夹后,点击**导出** + + + +6. 将会得到三个如下文件: + + + +spineboy.skel 包含了 skeleton animation 数据,spineboy.atlas 包含了 texture atlas 信息,导出的图片可能有多张,每张图片都代表了 texture altas 中的一页 + +## 2. 在 Galacean 编辑器中导入资产 +从 Spine 编辑器导出资产后,第二步就要将资产导入至 Galacean 编辑器了。打开编辑器后,将导出的文件直接拖入到[资产面板](https://antg.antgroup.com/engine/docs/latest/cn/assets-interface)中,即可完成上传 + + + +也可以点击资产面板的上传按钮进行上传: + + + +上传完成后,在资产面板中能够看到上传的 spine 素材。 + +### SpineSkeletonData 资产 + + + +SpineSkeletonData 资产存储了 skeleton 数据,以及对生成的 SpineAtlas 资产的引用 +点击资产后,能够在检查器中预览 Spine 动画,预览面板中能够切换`皮肤`和`动画片段`: + + + +### SpineAtlas 资产 + + + +SpineAtlas 资产存储了texture atlas 文件,并包含了其对所需 Texture 资产的引用。 +点击资产后,能够在检查器中查看其引用的 Texture 资产,以及 Spine 的图集信息 + + + +### 资产更新 +如若需要更新你的 Spine 资产。从 Spine 编辑器中重新导出资产,并再次导入到 Galacean 编辑器中覆盖原有文件即可。 + + +## 3. 添加 Spine 动画 + +完成资产上传后,第三步,需要将 Spine 动画添加至场景中。一共有三种方式: + +1. 拖入添加 + +拖入添加是最快捷的一种方式。点击 SpineSkeletonData 资产,按住后拖动到视图区,就能快速创建一个添加了 Spine 组件的实体,并指定资产为刚刚选中的 SpineSkeletonData 资产。 + + + +2. 快速添加 + +点击左上角的快速添加按钮,选择 `2D Object`>`SpineAnimationRenderer`, + + + +添加完成后,能够看到一个新的实体,挂载了 Spine 组件;点击 Resource 属性,选择上传的 SpineSkeletonData 资产,就能看到 Spine 动画啦 + + + +3. 手动添加 + +手动添加的方式与快速添加类似,不过需要在节点树中手动创建一个新的实体,并通过检查器的 AddComponent 按钮添加 Spine 组件 + + + +添加了 Spine 组件后,同样需要指定组件的 Resource,也就是 Spine 组件要渲染的 SpineSkeletonData 资产。 + +### Spine 组件配置 +以上三种添加 Spine 动画的方法实际上本质其实是相同的,都是通过给实体 `添加 Spine 组件` ,来让 Spine 动画添加至场景中的。 + +Spine 组件的配置如下: + + + +通过 Spine 组件能够配置 Spine 动画的资产以及默认状态: + +- Resource:Spine 动画的资源 ( SpineSkeletonData 资产 ) +- Animation:默认播放的动画名称 +- Loop:默认播放的动画是否循环 +- Skin:默认的皮肤名称 +- Scale:默认的缩放系数 +- Priority:渲染优先级 + +## 4. 项目导出 +最终,完成场景编辑器后,可以参考[项目导出](https://antg.antgroup.com/engine/docs/latest/cn/assets-build) 流程,导出编辑器项目。 + + + +



+下一章节:[在代码中使用 Galacean Spine 运行时](/docs/graphics/2D/spine/runtime) + diff --git a/docs/zh/graphics/2D/spine/example.md b/docs/zh/graphics/2D/spine/example.md new file mode 100644 index 0000000000..245ba4c494 --- /dev/null +++ b/docs/zh/graphics/2D/spine/example.md @@ -0,0 +1,41 @@ +--- +order: 3 +title: Spine 示例 +type: 图形 +group: Spine +label: Graphics/2D/Spine/example +--- + +**动画控制** + +该示例展示了如何通过 setAnimation 与 addAnimation API 来编排 spine 动画队列: + + +**跟踪射击** + +该示例展示了通过修改 IK 骨骼位置,来实现瞄准射击的效果: + + +**局部换肤** + +该示例展示了修改插槽中的附件,实现局部换装的效果: + + +**整体换肤** + +该示例展示了通过 setSkin 方法,实现整体换肤的效果: + + +**皮肤混搭** + +该示例展示了在运行时,通过组合新的皮肤,实现混搭的效果: + + +**物理** + +该示例展示了 spine 4.2 版本,基于物理的动画效果: + + + + +下一章节:[版本与性能](/docs/graphics/2D/spine/other) \ No newline at end of file diff --git a/docs/zh/graphics/2D/spine/other.md b/docs/zh/graphics/2D/spine/other.md new file mode 100644 index 0000000000..131d24583f --- /dev/null +++ b/docs/zh/graphics/2D/spine/other.md @@ -0,0 +1,32 @@ +--- +order: 4 +title: 版本与性能 +type: 图形 +group: Spine +label: Graphics/2D/Spine/other +--- + +### Spine 版本 +@galacen/engine-spine 自 1.2 版本后开始支持 spine 4.x 版本。
+从 1.2 版本后,@galacen/engine-spine 包的 major version 和 minor version 与 spine 版本完全对应,版本对照如下:
+- @galacean/engine-spine <= 1.2 对应 spine version 3.8 +- @galacean/engine-spine 4.0 对应 spine version 4.0 +- @galacean/engine-spine 4.1 对应 spine version 4.1 +- @galacean/engine-spine 4.2 对应 spine version 4.2 +- ..... + +目前已发布 4.2 beta 版本,4.1, 4.0 版本会陆续发布 + +### 版本升级 +升级到编辑器 1.3 版本后。除了需要在编辑器的[项目设置](https://antg.antgroup.com/engine/docs/latest/cn/interface-menu#%E9%A1%B9%E7%9B%AE%E8%AE%BE%E7%BD%AE)中升级引擎版本外,由于导出 JSON 或者二进制的 Spine 编辑器版本需要与运行时版本[保持一致](https://zh.esotericsoftware.com/spine-versioning#%E5%90%8C%E6%AD%A5%E7%89%88%E6%9C%AC),所以编辑器升级到 1.3 后,`还需要重新导出 4.2 版本的 Spine 资产并上传到编辑器,通过文件覆盖完成资产的更新`。 + +### 性能建议 +这里提供一些优化 spine 动画性能的方法: + +1. 使用二进制文件(.skel)的形式导出 skeleton,二进制文件的体积更小,加载更快。 +2. 建议将附件打包到尽可能少的atlas页中, 并根据绘制顺序将附件分组置入atlas页以防止多余的material切换. 请参考:[Spine 纹理打包:文件夹结构](https://zh.esotericsoftware.com/spine-texture-packer#%E6%96%87%E4%BB%B6%E5%A4%B9%E7%BB%93%E6%9E%84)了解如何在你的Spine atlas中编排 atlas 区域。 +3. 少用裁减功能。Spine 的裁减实现是通过动态裁减三角形实现的,性能开销很大。 +4. 尽可能少地使用atlas page textures。即,导出是贴图的数量尽可能控制在一张。 + +### 提问 +对于 Spine 有任何问题,欢迎在 @galacean/engine-spine [创建 issue](https://github.com/galacean/engine-spine/issues/new) \ No newline at end of file diff --git a/docs/zh/graphics/2D/spine/overview.md b/docs/zh/graphics/2D/spine/overview.md new file mode 100644 index 0000000000..253c7e67de --- /dev/null +++ b/docs/zh/graphics/2D/spine/overview.md @@ -0,0 +1,24 @@ +--- +order: 0 +title: Spine总览 +type: 图形 +group: Spine +label: Graphics/2D/Spine/overview +--- + +Spine 动画是一款针对游戏开发的 2D 骨骼动画,它通过将图片绑定到骨骼上,然后再控制骨骼实现动画,它可以满足程序对动画的控制与自由度,同时也为美术与设计提供了更高效和简洁的工作流。 +相较于传统的帧动画,Spine 动画具有以下优势: + +- **更小的体积:** 传统的动画需要提供每一帧图片。而 Spine 动画只保存骨骼的动画数据,它所占用的空间非常小。 +- **美术需求:** Spine 动画需要的美术资源更少,能为您节省出更多的人力物力更好的投入到游戏开发中去。 +- **流畅性:** Spine 动画使用差值算法计算中间帧,这能让你的动画总是保持流畅的效果。 +- **装备附件:** 图片绑定在骨骼上来实现动画。如果你需要可以方便的更换角色的装备满足不同的需求。甚至改变角色的样貌来达到动画重用的效果。 +- **混合:** 动画之间可以进行混合。比如一个角色可以开枪射击,同时也可以走、跑、跳或者游泳。 +- **程序动画:** 可以通过代码控制骨骼,比如可以实现跟随鼠标的射击,注视敌人,或者上坡时的身体前倾等效果。 + + +本章节会为大家介绍, +- [如何在 Galacean 编辑器中使用 Spine 动画](/docs/graphics/2D/spine/editor) +- [如何在代码中使用 Galacean spine 运行时](/docs/graphics/2D/spine/runtime) +- [Spine 动画示例](/docs/graphics/2D/spine/example) +- [其他内容(版本,性能)](/docs/graphics/2D/spine/other) \ No newline at end of file diff --git a/docs/zh/graphics/2D/spine/runtime.md b/docs/zh/graphics/2D/spine/runtime.md new file mode 100644 index 0000000000..7e63acc975 --- /dev/null +++ b/docs/zh/graphics/2D/spine/runtime.md @@ -0,0 +1,382 @@ +--- +order: 2 +title: 在代码中使用 +type: 图形 +group: Spine +label: Graphics/2D/Spine/runtime +--- + +本章节为大家介绍如何在代码中使用 Galacean Spine 运行时。 + +无论是通过编辑器导出的项目,或者 procode 项目,都需要通过安装 @galacean/engine-spine (即Galacean Spine 运行时) 来实现 Spine 动画的加载与渲染。 + +## 安装 +通过 npm 安装 +```typescript +npm install @galacean/engine-spine --save +``` +安装成功后,需要在代码中引入 +```typescript +import { SpineAnimationRenderer } from "@galacean/engine-spine"; +``` +安装并导入 `@galacean/engine-spine` 后,编辑器的 resourceManager 才能识别并加载 Spine 动画资产。 +Galacean spine 加载器既能加载编辑器上传的资产,也能过加载自定义上传的资产。 + +## 加载资产并添加至场景 + +### 加载 Galacean 编辑器中的上传的资产 +导出编辑器项目后,`已添加至场景中的 Spine 动画,会在加载场景文件时,自动完成加载`: + +```typescript +// 加载场景文件时,已添加至场景中的 Spine 动画会自行完成加载 +await engine.resourceManager.load({ + url: projectInfo.url, + type: AssetType.Project, +}) +``` + +若未添加至场景中,则需要在代码中手动加载,步骤如下: +1. 首先,需要找到 Spine 动画的资产链接,点击 Galacean 编辑器的下载按钮,选择 project URL,拷贝 project.json 后打开,找到上传的 spine 动画文件(skel / json): + + + +找到 spine 资产文件 json 或 skel: + + +2. 使用 resourceManager 加载 + +得到 spine 的骨骼文件资产链接后,需要使用 resourceManager 进行加载。手动加载时,添加 Spine 至场景中,需要创建一个新的实体并添加 Spine 组件,代码如下: +```typescript +import { SpineAnimationRenderer } from '@galacean/engine-spine'; + +// 加载并得到 spine 资源 +const spineResource = await engine.resourceManager.load( + { + url: 'https://galacean.spineboy.json', // 编辑器资产 + type: 'spine', // 必须指定加载器类型为 spine + }, +); +// 创建一个新的实体 +const spineEntity = new Entity(engine); +// 添加 spine 组件 +const spine = spineEntity.addComponent(SpineAnimationRenderer); +// 设置动画资源 +spine.resource = spineResource; +// 添加至场景 +root.addChild(spineEntity); +``` +### 加载自定义上传的资产 +1. 加载资产 + +如果你的 Spine 资产未通过 Galacean 编辑器进行上传,而是通过三方平台上传至 CDN,同样能够通过 Galacean Spine 运行时加载器进行加载。 +```typescript +const resource = await engine.resourceManager.load( + { + url: 'https://your.spineboy.json', // 自定义上传的资产 + type: 'spine', // 必须指定加载器类型为 spine + }, +); +``` +加载自定义上传的资产时: +- 当传递参数为 url 时,`需要保证 atlas 和 texture 资源与骨骼文件在相同目录下`,即:
+https://your.spineboy.json
+https://your.spineboy.atlas
+https://your.spineboy.png
+三个文件相同目录 + +- 当传递参数为 urls (多链接)时,则无需满足相同目录的条件: +```typescript +const resource = await engine.resourceManager.load( + { + urls: [ + 'https://your.spineboy.json', + 'https://ahother-path1.spineboy.altas', + 'https://ahother-path2.spineboy.png', + ], // 自定义上传的资产 + type: 'spine',// 必须指定加载器类型为 spine + }, +); +``` +- 若不传递 texture 地址,那么加载器会从 atlas 文件中读取 texture 的图片名称,并从 atlas 文件的相对路径下查找 texture 资源。
+- 若自定上传的资产没有文件后缀(比如 blob 协议的 URL),则可以通过给链接添加 URL query 参数,例如:
+https://your.spineboyjson?ext=.json
+https://your.spineboyatlas?ext=.atlas
+或者添加 fileExtensions 参数来指定资源后缀类型: +```typescript +const resource = await engine.resourceManager.load( + { + urls: [ + 'https://your.spineboyjson', + 'https://ahother-path1.spineboyatlas', + 'https://ahother-path2.spineboypng', + ], // 自定义上传的资产 + type: 'spine', + fileExtensions: [ + 'json', // 指定第一个文件为 json 后缀 + 'atlas', // 指定第二个文件为 atlas 后缀 + 'png', // // 指定第三个文件为 atlas 后缀 + ] + }, +); +``` +- 若 Spine 动画的 texure atlas 包含多张图片,则需要按照 atlas 文件中图片的顺序传入图片地址。 + +2. 添加至场景 + +加载完毕后,需要手动创建实体,并添加 Spine 组件: +```typescript +import { SpineAnimationRenderer } from '@galacean/engine-spine'; + +const spineResource = await engine.resourceManager.load( + { + url: 'https://your.spineboy.json', // 自定义上传的资产 + type: 'spine', + }, +); +// 创建实体 +const spineEntity = new Entity(engine); +// 添加 spine 组件 +const spine = spineEntity.addComponent(SpineAnimationRenderer); +// 设置动画资源 +spine.resource = spineResource; +// 添加至场景 +root.addChild(spineEntity); +``` + + +## 使用运行时 API + +在[前一个章节]()中,为大家介绍了编辑器中 Spine 组件的配置项。通过这些配置项目,能够对 Spine 动画进行一些基础的配置。 + +若要对 Spine 动画进行更复杂的操作,还需要在[脚本](https://antg.antgroup.com/engine/docs/latest/cn/script)中使用运行时 API。 + +Spine 组件暴露了两个重要的对象 (AnimationState 和 Skeleton) 来进行动画控制与骨架操作,以实现更加复杂的效果。下面是详细的使用方式: + +#### 动画控制 AnimationState +在脚本中,你能够通过以下方式获取到 [AnimationState](https://zh.esotericsoftware.com/spine-api-reference#AnimationState) 对象,使用 AnimationState 对象能够实现更加复杂的动画操作。 +```typescript +class YourAmazingScript { + + onStart() { + const spine = this.entity.getComponent(SpineAnimationRenderer); + const { state } = spine; // AnimationState 对象 + } + +} +``` +##### **播放动画** +首先,我们来介绍一下最常用的 API:[setAnimation](https://zh.esotericsoftware.com/spine-api-reference#AnimationState-setAnimation) +```typescript +state.setAnimation(0, 'animationName', true) +``` +setAnimation 函数接受三个参数: + +- TrackIndex:动画轨道序号 +- animationName:动画名称 +- loop:是否循环播放 + +后两个参数很好理解,第一个参数则包含了 Spine 动画的一个概念:**Track** (轨道) +> Spine 动画在播放时,需要指定一个动画轨道。借助动画轨道,Spine 能够分层应用动画,每一个轨道都能够存储动画与播放参数,轨道的编号从 0 开始累加。在动画应用后,Spine 会从低轨道到高轨道依次应用动画,高轨道上的动画将会覆盖低轨道上的动画。
+动画轨道有很多用途,例如,轨道 0 可以有行走、奔跑、游泳或其他动画,轨道 1 可以有一个只为手臂和开枪设置了关键帧的射击动画。此外,为高层轨道设置TrackEntry alpha可使其与下面的轨道混合。例如,轨道 0 可以有一个行走动画,轨道 1 可以有一个跛行动画。当玩家受伤时,增加轨道 1 的alpha值,跛行就会加重。 + + +##### **设置过渡** +调用 setAnimation 方法后,会立即切换当前轨道的动画。如果你需要动画切换时有过渡效果,需要设置过渡的持续时间。可以通过 [AnimationStateData](https://zh.esotericsoftware.com/spine-api-reference#AnimationStateData) 的 API 来进行设置: +```typescript +class YourAmazingScript { + + onStart() { + const spine = this.entity.getComponent(SpineAnimationRenderer); + const { state } = spine; // AnimationState 对象 + const { data } = state; // AnimationStateData 对象 + data.defaultMix = 0.2; // 设置默认过渡持续时间 + data.setMix('animationA', 'animationB', 0.3); // 设置两个指定动画的过渡持续时间 + } + +} +``` + +- defaultMix 是当两个动画间没有定义混合持续时间时的默认持续时间 +- setMix 函数接受三个参数,前两个是需要设置过渡时间的动画名称,第三个则是动画混合的持续时间 +##### **动画队列** +Spine 还提供了 [addAnimation](https://zh.esotericsoftware.com/spine-api-reference#AnimationState-addAnimation2) 方法来实现动画的队列播放: +```typescript +state.setAnimation(0, 'animationA', false); // 在轨道 0 播放动画 A +state.addAnimation(0, 'animationB', true, 0); // 在动画 A 之后后,添加动画 B,并循环播放 +``` +addAnimation 接受 4 个参数: + +- TrackIndex:动画轨道 +- animationName:动画名称 +- loop:是否循环播放 +- delay:延迟时间 + +前三个参数很好理解,这里解释一下第四个参数: +delay 代表了前一个动画的持续时间。 +当 delay > 0 时(假设 delay 为 1),前一个动画会在播放 1 秒后,切换到下一个动画。如下图所示: + + + +如果动画 A 的时长小于 1 秒,则会根据是否设置了循环播放:循环播放直至 1 秒,或者播放完毕后,保持在动画播放完毕的状态直至 1 秒。 +当 delay = 0 时,下一个动画会在前一个动画播放完毕后播放,如下图所示: + + + +假设动画 A 的时长为 1 秒,过渡持续时间为 0.2 秒,当 delay 设置为 0 时,动画 B 会从 1 - 0.2 也就是 0.8 秒开始过渡到动画 B。 +当 delay < 0 时,上一个动画未播放完毕前,下一个动画就会开始播放,如下图所示: +同样假设动画 A 的时长为 1 秒,过渡持续时间为 0.2 秒,动画 B 则会从 0.6 秒开始过渡到动画 B。 + + + +除了 addAnimation 外,还能够通过 addEmptyAnimation 方法添加空动画。空动画能够让动画回到初始状态。 + +addEmptyAnimation 接受三个参数:TrackIndex,mixDuration 和 delay。TrackIndex 和 delay 参数与 addAnimation 一样。 mixDuration 是过渡持续时间,动画会在 mixDuration 时间内逐渐回到初始状态。如下图所示(右侧棕色区域即是空动画), + + + +##### **轨道参数** +setAnimation 和 addAnimation 方法都会返回一个对象:TrackEntry。TrackEntry 提供了更多的参数来进行动画控制。 +例如: + +- timeScale:控制动画播放的速度 +- animationStart:控制动画播放的开始时间 +- apha:当前动画应用轨道的混合系数 +- ... + +更多参数可以参考 [TrackEntry 官方文档](https://zh.esotericsoftware.com/spine-api-reference#TrackEntry) +##### **动画事件** + + + +当调用 AnimationState API 进行动画控制时,会触发如上图所示的事件。 +在新的动画开始播放时,会触发 Start 事件,当动画在动画队列中移除或者中断时,会触发 End 事件。当动画播放完毕时,无论是否循环,都会触发 Complete 事件。 + +全部的事件以及详细解释请参考:[Spine 动画事件官方文档](https://zh.esotericsoftware.com/spine-unity-events) + +这些事件能够通过 [AnimationState.addListener](https://zh.esotericsoftware.com/spine-api-reference#AnimationState-addListener) 进行监听。 +```typescript +class YourAmazingScript { + + onStart() { + const spine = this.entity.getComponent(SpineAnimationRenderer); + const { state } = spine; // AnimationState 对象 + state.addListener({ + start: (entry: TrackEntry) => { + // call back function + }, + complete: (entry: TrackEntry) => { + // call back function + }, + end: (entry: TrackEntry) => { + // call back function + }, + interrupt: (entry: TrackEntry) => { + // call back function + }, + dispose: (entry: TrackEntry) => { + // call back function + }, + event: (entry: TrackEntry) => { + // call back function + }, + }) + } + +} +``` + +#### 骨架操作 Skeleton +在脚本中,你能够通过以下方式获取到 [Skeleton](https://zh.esotericsoftware.com/spine-api-reference#Skeleton) 对象,来访问骨骼、插槽、附件等,并进行骨架操作。 +```typescript +class YourAmazingScript { + + onStart() { + const spine = this.entity.getComponent(SpineAnimationRenderer); + const { skeleton } = spine; // Skeleton 对象 + } + +} +``` +下面是一些常用的操作: +##### **修改骨骼位置** +通过 Skeleton API 能够修改 Spine 骨骼的位置,比较常见的应用是:可以通过设置 IK 的目标骨骼,来实现瞄准/跟随效果。 +```typescript +class YourAmazingScript { + + onStart() { + const spine = this.entity.getComponent(SpineAnimationRenderer); + const { skeleton } = spine; // Skeleton 对象 + const bone = skeleton.findBone('aim-target'); + bone.x = targetX; + bone.y = targetY; + } + +} +``` +注意:由于应用动画会修改骨骼位置,所以如果 Spine 在播放动画, 那么骨骼位置的修改需要在应用动画之后,也就是在脚本的 onLateUpdate 生命周期中进行操作。 + +##### **附件更换** +通过 Skeleton API 能够替换[插槽](https://zh.esotericsoftware.com/spine-slots)内的[附件](https://zh.esotericsoftware.com/spine-attachments)。通过切换附件,能够实现局部换装的效果。 +```typescript +class YourAmazingScript { + + onStart() { + const spine = this.entity.getComponent(SpineAnimationRenderer); + const { skeleton } = spine; // Skeleton 对象 + // 根据名称查找插槽 + const slot = skeleton.findSlot('slotName'); + // 按名称从骨架皮肤或默认皮肤获取附件 + const attachment = skeleton.getAttachment(slot.index, 'attachmentName'); + // 设置插槽附件 + slot.attachment = attachment; + // 或者由骨架setAttachment方法来设置插槽附件 + skeleton.setAttachment('slotName', 'attachmentName'); + } +} +``` +注意:由于应用动画会修改插槽内的附件,所以如果 Spine 在播放动画,那么附件更换的操作需要在应用动画之后,也就是在脚本的 onLateUpdate 生命周期中进行操作。 +##### **换肤与混搭** +**换肤** + +通过 Skeleton 的 [setSkin](https://zh.esotericsoftware.com/spine-api-reference#Skeleton-setSkin) API 能够根据皮肤名称实现整体换肤。 +```typescript +class YourAmazingScript { + + onStart() { + const spine = this.entity.getComponent(SpineAnimationRenderer); + const { skeleton } = spine; // Skeleton 对象 + // 根据皮肤名称设置皮肤 + skeleton.setSkinByName("full-skins/girl"); + // 回到初始位置(必须调用,否则渲染可能出现错乱) + skeleton.setSlotsToSetupPose(); + } + +} +``` +**混搭** + +在 Spine 编辑器中,设计师可以为每一个外观和装备准备皮肤,然后在运行时把他们组合成一个新的皮肤。下面的代码展示了如果通过 addSkin 来添加选定的皮肤的: +```typescript +class YourAmazingScript { + + onStart() { + const spine = this.entity.getComponent(SpineAnimationRenderer); + const { skeleton } = spine; // Skeleton 对象 + const mixAndMatchSkin = new spine.Skin("custom-girl"); + mixAndMatchSkin.addSkin(skeletonData.findSkin("skin-base")); + mixAndMatchSkin.addSkin(skeletonData.findSkin("nose/short")); + mixAndMatchSkin.addSkin(skeletonData.findSkin("eyelids/girly")); + mixAndMatchSkin.addSkin(skeletonData.findSkin("eyes/violet")); + mixAndMatchSkin.addSkin(skeletonData.findSkin("hair/brown")); + mixAndMatchSkin.addSkin(skeletonData.findSkin("clothes/hoodie-orange")); + mixAndMatchSkin.addSkin(skeletonData.findSkin("legs/pants-jeans")); + mixAndMatchSkin.addSkin(skeletonData.findSkin("accessories/bag")); + mixAndMatchSkin.addSkin(skeletonData.findSkin("accessories/hat-red-yellow")); + this.skeleton.setSkin(mixAndMatchSkin); + } + +} +``` +代码中皮肤的名称来自于 mix-and-match 示例。 + +下一个章节会给大家展示全部的 [Spine 示例](/docs/graphics/2D/spine/example) \ No newline at end of file From 6fe678da9ce0a804bc3a4ee3e6a8a055ba7bc47f Mon Sep 17 00:00:00 2001 From: johanzhu Date: Thu, 1 Aug 2024 16:49:17 +0800 Subject: [PATCH 3/4] feat: fix doc --- docs/zh/graphics/2D/spine/editor.md | 41 +++++----- docs/zh/graphics/2D/spine/runtime.md | 109 +++++++++++++++++++++------ 2 files changed, 107 insertions(+), 43 deletions(-) diff --git a/docs/zh/graphics/2D/spine/editor.md b/docs/zh/graphics/2D/spine/editor.md index 0312ebf5e0..330d4d8b26 100644 --- a/docs/zh/graphics/2D/spine/editor.md +++ b/docs/zh/graphics/2D/spine/editor.md @@ -6,12 +6,13 @@ group: Spine label: Graphics/2D/Spine/editor --- -本章节为大家介绍如何在 Galacean 编辑器中使用 Spine 动画。 +Galacean 编辑器内置了对 Spine 动画的支持,无需额外下载或配置,开箱即用,大大简化了开发流程。本章节为大家介绍如何在 Galacean 编辑器中使用 Spine 动画。 + +>编辑器版本依赖请参照:[版本/性能章节](/docs/graphics/2D/spine/other) -Galacean 编辑器内置了对 Spine 动画的支持,无需额外下载或配置,开箱即用,大大简化了开发流程。 ## 1. 从 Spine 编辑器导出资产 -第一步,需要从 Spine 编辑器导出你的 Spine 动画素材,你可以在《Spine用户指南》中找到完整的步骤, 说明如何: +第一步,需要从 Spine 编辑器导出你的 Spine 动画素材,你可以在[《Spine用户指南》](https://zh.esotericsoftware.com/spine-user-guide) 中找到完整的步骤, 说明如何: 1. [导出 skeleton 和 animation 数据](https://zh.esotericsoftware.com/spine-export) 2. [导出包含 skeleton 图像的 texture atlases](https://zh.esotericsoftware.com/spine-texture-packer) @@ -20,60 +21,60 @@ Galacean 编辑器内置了对 Spine 动画的支持,无需额外下载或配 1. 完成动画制作后,单击 `Spine 菜单`>`导出` ,打开导出窗口 - +Export panel in Spine editor 2. 选择导出窗口左上角的**二进制** ( 推荐使用二进制,以二进制格式而不是JSON格式导出,会使文件体积更小,加载更快 - +Export window in Spine editor 3. 勾选上,**纹理图集**的打包复选框 - +Click packing texture atlas button in Export window 4. 点击 **打包设置** 这里建议勾选 `2 的幂数`;`预乘`和`溢出`两项请勿勾选 完成打包设置后,点击**确定** - +Texture pack window in Spine Editor 5. 回到导出窗口,选择导出文件夹后,点击**导出** - +Click export button in texture pack window 6. 将会得到三个如下文件: - +Spine assets in folder spineboy.skel 包含了 skeleton animation 数据,spineboy.atlas 包含了 texture atlas 信息,导出的图片可能有多张,每张图片都代表了 texture altas 中的一页 ## 2. 在 Galacean 编辑器中导入资产 从 Spine 编辑器导出资产后,第二步就要将资产导入至 Galacean 编辑器了。打开编辑器后,将导出的文件直接拖入到[资产面板](https://antg.antgroup.com/engine/docs/latest/cn/assets-interface)中,即可完成上传 - +Drag spine assets into Galacean editor 也可以点击资产面板的上传按钮进行上传: - +Use upload button to upload spine assets 上传完成后,在资产面板中能够看到上传的 spine 素材。 ### SpineSkeletonData 资产 - +Spine skeleton data asset icon SpineSkeletonData 资产存储了 skeleton 数据,以及对生成的 SpineAtlas 资产的引用 点击资产后,能够在检查器中预览 Spine 动画,预览面板中能够切换`皮肤`和`动画片段`: - +Spine skeleton data preview ### SpineAtlas 资产 - +Spine atlas asset SpineAtlas 资产存储了texture atlas 文件,并包含了其对所需 Texture 资产的引用。 点击资产后,能够在检查器中查看其引用的 Texture 资产,以及 Spine 的图集信息 - +Spine atlas preview ### 资产更新 如若需要更新你的 Spine 资产。从 Spine 编辑器中重新导出资产,并再次导入到 Galacean 编辑器中覆盖原有文件即可。 @@ -87,23 +88,23 @@ SpineAtlas 资产存储了texture atlas 文件,并包含了其对所需 Textur 拖入添加是最快捷的一种方式。点击 SpineSkeletonData 资产,按住后拖动到视图区,就能快速创建一个添加了 Spine 组件的实体,并指定资产为刚刚选中的 SpineSkeletonData 资产。 - +Drag Spine skeleton data asset into viewport 2. 快速添加 点击左上角的快速添加按钮,选择 `2D Object`>`SpineAnimationRenderer`, - +Quick add Spine animation renderer 添加完成后,能够看到一个新的实体,挂载了 Spine 组件;点击 Resource 属性,选择上传的 SpineSkeletonData 资产,就能看到 Spine 动画啦 - +Select spine skeleton data asset in component panel 3. 手动添加 手动添加的方式与快速添加类似,不过需要在节点树中手动创建一个新的实体,并通过检查器的 AddComponent 按钮添加 Spine 组件 - +Use add component to add spine animation renderer 添加了 Spine 组件后,同样需要指定组件的 Resource,也就是 Spine 组件要渲染的 SpineSkeletonData 资产。 @@ -112,7 +113,7 @@ SpineAtlas 资产存储了texture atlas 文件,并包含了其对所需 Textur Spine 组件的配置如下: - +Spine animation renderer component config 通过 Spine 组件能够配置 Spine 动画的资产以及默认状态: diff --git a/docs/zh/graphics/2D/spine/runtime.md b/docs/zh/graphics/2D/spine/runtime.md index 7e63acc975..f59b032aa7 100644 --- a/docs/zh/graphics/2D/spine/runtime.md +++ b/docs/zh/graphics/2D/spine/runtime.md @@ -8,10 +8,9 @@ label: Graphics/2D/Spine/runtime 本章节为大家介绍如何在代码中使用 Galacean Spine 运行时。 -无论是通过编辑器导出的项目,或者 procode 项目,都需要通过安装 @galacean/engine-spine (即Galacean Spine 运行时) 来实现 Spine 动画的加载与渲染。 ## 安装 -通过 npm 安装 +无论是通过编辑器导出的项目,或者 procode 项目,都需要通过安装 @galacean/engine-spine (即Galacean Spine 运行时) 来实现 Spine 动画的加载与渲染。 ```typescript npm install @galacean/engine-spine --save ``` @@ -38,10 +37,10 @@ await engine.resourceManager.load({ 若未添加至场景中,则需要在代码中手动加载,步骤如下: 1. 首先,需要找到 Spine 动画的资产链接,点击 Galacean 编辑器的下载按钮,选择 project URL,拷贝 project.json 后打开,找到上传的 spine 动画文件(skel / json): - +Project export panel 找到 spine 资产文件 json 或 skel: - +Spine skeleton data file 2. 使用 resourceManager 加载 @@ -146,13 +145,77 @@ root.addChild(spineEntity); ## 使用运行时 API -在[前一个章节]()中,为大家介绍了编辑器中 Spine 组件的配置项。通过这些配置项目,能够对 Spine 动画进行一些基础的配置。 +在[前一个章节](/docs/graphics/2D/spine/editor)中,为大家介绍了编辑器中 Spine 组件的配置项。 +本小节会更加详细介绍在代码中如何使用 Spine 组件的各个 API。 + +Spine 组件一共暴露了以下几个属性: +- resource:Spine 动画资源。设置了资源后,Spine 组件会读取资源数据,并渲染出 Spine 动画 +- setting:渲染设置。用于控制开启裁减和调整图层间隔 +- defaultState:默认状态。与编辑器的配置项对应,用于设置默认状态下 Spine 动画的动画,皮肤,缩放 +- state:动画状态对象。用于进行更加复杂动画控制,如:队列播放,循环控制等。 +- skeleton:骨架对象。用于进行更加复杂的骨架操作,如:附件替换,换肤等。 + +下面是更详细的使用介绍: + +### 资源设置 +首先是资源的设置。Spine 组件需要设置资源后,才能完成 Spine动画的渲染。在上一个章节,「加载资产并添加至场景」中,已经为大家展示了设置资产的方式: +```typescript +import { SpineAnimationRenderer } from '@galacean/engine-spine'; + +const spineResource = await engine.resourceManager.load( + { + url: 'https://your.spineboy.json', + type: 'spine', + }, +); +const spineEntity = new Entity(engine); +const spine = spineEntity.addComponent(SpineAnimationRenderer); +spine.resource = spineResource; // 设置 Spine 资产 +root.addChild(spineEntity); +``` + +### 渲染设置 +在脚本中,你可以通过以下方式修改 Spine 的渲染设置,一般情况下,使用默认值即可。 +```typescript +class YourAmazingScript { + + onStart() { + const spine = this.entity.getComponent(SpineAnimationRenderer); + spine.setting.zSpacing = 0.01; // 设置图层间隔 + spine.setting.useClipping = true; // 开启或关闭裁减,默认开启 + } + +} +``` -若要对 Spine 动画进行更复杂的操作,还需要在[脚本](https://antg.antgroup.com/engine/docs/latest/cn/script)中使用运行时 API。 +### 默认状态 +在脚本中,你可以通过以下方式修改 Spine 动画的默认状态: +```typescript +class YourAmazingScript { + + onStart() { + const spineResource = await engine.resourceManager.load( + { + url: 'https://your.spineboy.json', + type: 'spine', + }, + ); + const spineEntity = new Entity(engine); + const spine = spineEntity.addComponent(SpineAnimationRenderer); + spine.defaultState.animationName = 'your-default-animation-name'; // 默认播放的动画名称 + spine.defaultState.loop = true; // 默认播放的动画是否循环 + spine.defaultState.skinName = 'default'; // 默认皮肤名称 + spine.defaultState.scale = 0.02; // 默认缩放 + spine.resource = spineResource; // 设置资源 + rootEntity.addChild(spineEntity); // 添加至场景,此时组件激活 + } + +} +``` +注意:默认状态仅在 Spine 组件激活和资源设置时生效。动态修改动画、皮肤、缩放请使用 state 与 skeleton 属性中的方法(见下面的章节)。 -Spine 组件暴露了两个重要的对象 (AnimationState 和 Skeleton) 来进行动画控制与骨架操作,以实现更加复杂的效果。下面是详细的使用方式: -#### 动画控制 AnimationState +### 动画控制 在脚本中,你能够通过以下方式获取到 [AnimationState](https://zh.esotericsoftware.com/spine-api-reference#AnimationState) 对象,使用 AnimationState 对象能够实现更加复杂的动画操作。 ```typescript class YourAmazingScript { @@ -164,7 +227,7 @@ class YourAmazingScript { } ``` -##### **播放动画** +#### **播放动画** 首先,我们来介绍一下最常用的 API:[setAnimation](https://zh.esotericsoftware.com/spine-api-reference#AnimationState-setAnimation) ```typescript state.setAnimation(0, 'animationName', true) @@ -180,7 +243,7 @@ setAnimation 函数接受三个参数: 动画轨道有很多用途,例如,轨道 0 可以有行走、奔跑、游泳或其他动画,轨道 1 可以有一个只为手臂和开枪设置了关键帧的射击动画。此外,为高层轨道设置TrackEntry alpha可使其与下面的轨道混合。例如,轨道 0 可以有一个行走动画,轨道 1 可以有一个跛行动画。当玩家受伤时,增加轨道 1 的alpha值,跛行就会加重。 -##### **设置过渡** +#### **设置过渡** 调用 setAnimation 方法后,会立即切换当前轨道的动画。如果你需要动画切换时有过渡效果,需要设置过渡的持续时间。可以通过 [AnimationStateData](https://zh.esotericsoftware.com/spine-api-reference#AnimationStateData) 的 API 来进行设置: ```typescript class YourAmazingScript { @@ -198,7 +261,7 @@ class YourAmazingScript { - defaultMix 是当两个动画间没有定义混合持续时间时的默认持续时间 - setMix 函数接受三个参数,前两个是需要设置过渡时间的动画名称,第三个则是动画混合的持续时间 -##### **动画队列** +#### **动画队列** Spine 还提供了 [addAnimation](https://zh.esotericsoftware.com/spine-api-reference#AnimationState-addAnimation2) 方法来实现动画的队列播放: ```typescript state.setAnimation(0, 'animationA', false); // 在轨道 0 播放动画 A @@ -215,26 +278,26 @@ addAnimation 接受 4 个参数: delay 代表了前一个动画的持续时间。 当 delay > 0 时(假设 delay 为 1),前一个动画会在播放 1 秒后,切换到下一个动画。如下图所示: - +animation delay > 0 如果动画 A 的时长小于 1 秒,则会根据是否设置了循环播放:循环播放直至 1 秒,或者播放完毕后,保持在动画播放完毕的状态直至 1 秒。 当 delay = 0 时,下一个动画会在前一个动画播放完毕后播放,如下图所示: - +animation delay = 0 假设动画 A 的时长为 1 秒,过渡持续时间为 0.2 秒,当 delay 设置为 0 时,动画 B 会从 1 - 0.2 也就是 0.8 秒开始过渡到动画 B。 当 delay < 0 时,上一个动画未播放完毕前,下一个动画就会开始播放,如下图所示: 同样假设动画 A 的时长为 1 秒,过渡持续时间为 0.2 秒,动画 B 则会从 0.6 秒开始过渡到动画 B。 - +animation delay < 0 除了 addAnimation 外,还能够通过 addEmptyAnimation 方法添加空动画。空动画能够让动画回到初始状态。 addEmptyAnimation 接受三个参数:TrackIndex,mixDuration 和 delay。TrackIndex 和 delay 参数与 addAnimation 一样。 mixDuration 是过渡持续时间,动画会在 mixDuration 时间内逐渐回到初始状态。如下图所示(右侧棕色区域即是空动画), - +Add empty animation api -##### **轨道参数** +#### **轨道参数** setAnimation 和 addAnimation 方法都会返回一个对象:TrackEntry。TrackEntry 提供了更多的参数来进行动画控制。 例如: @@ -244,9 +307,9 @@ setAnimation 和 addAnimation 方法都会返回一个对象:TrackEntry。Trac - ... 更多参数可以参考 [TrackEntry 官方文档](https://zh.esotericsoftware.com/spine-api-reference#TrackEntry) -##### **动画事件** +#### **动画事件** - +Animation event diagram 当调用 AnimationState API 进行动画控制时,会触发如上图所示的事件。 在新的动画开始播放时,会触发 Start 事件,当动画在动画队列中移除或者中断时,会触发 End 事件。当动画播放完毕时,无论是否循环,都会触发 Complete 事件。 @@ -285,7 +348,7 @@ class YourAmazingScript { } ``` -#### 骨架操作 Skeleton +### 骨架操作 在脚本中,你能够通过以下方式获取到 [Skeleton](https://zh.esotericsoftware.com/spine-api-reference#Skeleton) 对象,来访问骨骼、插槽、附件等,并进行骨架操作。 ```typescript class YourAmazingScript { @@ -298,7 +361,7 @@ class YourAmazingScript { } ``` 下面是一些常用的操作: -##### **修改骨骼位置** +#### **修改骨骼位置** 通过 Skeleton API 能够修改 Spine 骨骼的位置,比较常见的应用是:可以通过设置 IK 的目标骨骼,来实现瞄准/跟随效果。 ```typescript class YourAmazingScript { @@ -315,7 +378,7 @@ class YourAmazingScript { ``` 注意:由于应用动画会修改骨骼位置,所以如果 Spine 在播放动画, 那么骨骼位置的修改需要在应用动画之后,也就是在脚本的 onLateUpdate 生命周期中进行操作。 -##### **附件更换** +#### **附件更换** 通过 Skeleton API 能够替换[插槽](https://zh.esotericsoftware.com/spine-slots)内的[附件](https://zh.esotericsoftware.com/spine-attachments)。通过切换附件,能够实现局部换装的效果。 ```typescript class YourAmazingScript { @@ -335,7 +398,7 @@ class YourAmazingScript { } ``` 注意:由于应用动画会修改插槽内的附件,所以如果 Spine 在播放动画,那么附件更换的操作需要在应用动画之后,也就是在脚本的 onLateUpdate 生命周期中进行操作。 -##### **换肤与混搭** +#### **换肤与混搭** **换肤** 通过 Skeleton 的 [setSkin](https://zh.esotericsoftware.com/spine-api-reference#Skeleton-setSkin) API 能够根据皮肤名称实现整体换肤。 @@ -377,6 +440,6 @@ class YourAmazingScript { } ``` -代码中皮肤的名称来自于 mix-and-match 示例。 +代码中皮肤的名称来自 mix-and-match 示例。 下一个章节会给大家展示全部的 [Spine 示例](/docs/graphics/2D/spine/example) \ No newline at end of file From fcb33ff88a884d97a94acd7bdc8b9de6a53be640 Mon Sep 17 00:00:00 2001 From: johanzhu Date: Fri, 2 Aug 2024 15:05:39 +0800 Subject: [PATCH 4/4] feat: fix spine cpt name --- docs/zh/graphics/2D/spine/editor.md | 16 +++++++------- docs/zh/graphics/2D/spine/runtime.md | 31 +++++++++++++++------------- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/docs/zh/graphics/2D/spine/editor.md b/docs/zh/graphics/2D/spine/editor.md index 330d4d8b26..da3d625c57 100644 --- a/docs/zh/graphics/2D/spine/editor.md +++ b/docs/zh/graphics/2D/spine/editor.md @@ -86,7 +86,7 @@ SpineAtlas 资产存储了texture atlas 文件,并包含了其对所需 Textur 1. 拖入添加 -拖入添加是最快捷的一种方式。点击 SpineSkeletonData 资产,按住后拖动到视图区,就能快速创建一个添加了 Spine 组件的实体,并指定资产为刚刚选中的 SpineSkeletonData 资产。 +拖入添加是最快捷的一种方式。点击 SpineSkeletonData 资产,按住后拖动到视图区,就能快速创建一个添加了 SpineAnimationRenderer 组件的实体,并指定资产为刚刚选中的 SpineSkeletonData 资产。 Drag Spine skeleton data asset into viewport @@ -96,26 +96,26 @@ SpineAtlas 资产存储了texture atlas 文件,并包含了其对所需 Textur Quick add Spine animation renderer -添加完成后,能够看到一个新的实体,挂载了 Spine 组件;点击 Resource 属性,选择上传的 SpineSkeletonData 资产,就能看到 Spine 动画啦 +添加完成后,能够看到一个新的实体,挂载了 SpineAnimationRenderer 组件;点击 Resource 属性,选择上传的 SpineSkeletonData 资产,就能看到 Spine 动画啦 Select spine skeleton data asset in component panel 3. 手动添加 -手动添加的方式与快速添加类似,不过需要在节点树中手动创建一个新的实体,并通过检查器的 AddComponent 按钮添加 Spine 组件 +手动添加的方式与快速添加类似,不过需要在节点树中手动创建一个新的实体,并通过检查器的 AddComponent 按钮添加 SpineAnimationRenderer 组件 Use add component to add spine animation renderer -添加了 Spine 组件后,同样需要指定组件的 Resource,也就是 Spine 组件要渲染的 SpineSkeletonData 资产。 +添加了 SpineAnimationRenderer 组件后,同样需要指定组件的 Resource,也就是 SpineAnimationRenderer 组件要渲染的 SpineSkeletonData 资产。 -### Spine 组件配置 -以上三种添加 Spine 动画的方法实际上本质其实是相同的,都是通过给实体 `添加 Spine 组件` ,来让 Spine 动画添加至场景中的。 +### SpineAnimationRenderer 组件配置 +以上三种添加 Spine 动画的方法实际上本质其实是相同的,都是通过给实体 `添加 SpineAnimationRenderer 组件` ,来让 Spine 动画添加至场景中的。 -Spine 组件的配置如下: +SpineAnimationRenderer 组件的配置如下: Spine animation renderer component config -通过 Spine 组件能够配置 Spine 动画的资产以及默认状态: +通过 SpineAnimationRenderer 组件能够配置 Spine 动画的资产以及默认状态: - Resource:Spine 动画的资源 ( SpineSkeletonData 资产 ) - Animation:默认播放的动画名称 diff --git a/docs/zh/graphics/2D/spine/runtime.md b/docs/zh/graphics/2D/spine/runtime.md index f59b032aa7..006a323343 100644 --- a/docs/zh/graphics/2D/spine/runtime.md +++ b/docs/zh/graphics/2D/spine/runtime.md @@ -44,7 +44,7 @@ await engine.resourceManager.load({ 2. 使用 resourceManager 加载 -得到 spine 的骨骼文件资产链接后,需要使用 resourceManager 进行加载。手动加载时,添加 Spine 至场景中,需要创建一个新的实体并添加 Spine 组件,代码如下: +得到 spine 的骨骼文件资产链接后,需要使用 resourceManager 进行加载。手动加载时,添加 Spine 至场景中,需要创建一个新的实体并添加 SpineAnimationRenderer 组件,代码如下: ```typescript import { SpineAnimationRenderer } from '@galacean/engine-spine'; @@ -57,7 +57,7 @@ const spineResource = await engine.resourceManager.load( ); // 创建一个新的实体 const spineEntity = new Entity(engine); -// 添加 spine 组件 +// 添加 SpineAnimationRenderer 组件 const spine = spineEntity.addComponent(SpineAnimationRenderer); // 设置动画资源 spine.resource = spineResource; @@ -122,7 +122,7 @@ const resource = await engine.resourceManager.load( 2. 添加至场景 -加载完毕后,需要手动创建实体,并添加 Spine 组件: +加载完毕后,需要手动创建实体,并添加 SpineAnimationRenderer 组件: ```typescript import { SpineAnimationRenderer } from '@galacean/engine-spine'; @@ -134,7 +134,7 @@ const spineResource = await engine.resourceManager.load( ); // 创建实体 const spineEntity = new Entity(engine); -// 添加 spine 组件 +// 添加 SpineAnimationRenderer 组件 const spine = spineEntity.addComponent(SpineAnimationRenderer); // 设置动画资源 spine.resource = spineResource; @@ -145,20 +145,23 @@ root.addChild(spineEntity); ## 使用运行时 API -在[前一个章节](/docs/graphics/2D/spine/editor)中,为大家介绍了编辑器中 Spine 组件的配置项。 -本小节会更加详细介绍在代码中如何使用 Spine 组件的各个 API。 +在[前一个章节](/docs/graphics/2D/spine/editor)中,为大家介绍了编辑器中 SpineAnimationRenderer 组件的配置项。 +本小节会更加详细介绍在代码中如何使用 SpineAnimationRenderer 组件的各个 API。 -Spine 组件一共暴露了以下几个属性: -- resource:Spine 动画资源。设置了资源后,Spine 组件会读取资源数据,并渲染出 Spine 动画 -- setting:渲染设置。用于控制开启裁减和调整图层间隔 -- defaultState:默认状态。与编辑器的配置项对应,用于设置默认状态下 Spine 动画的动画,皮肤,缩放 -- state:动画状态对象。用于进行更加复杂动画控制,如:队列播放,循环控制等。 -- skeleton:骨架对象。用于进行更加复杂的骨架操作,如:附件替换,换肤等。 +SpineAnimationRenderer 组件继承于 Renderer,除了暴露 Renderer 的通用方法外,还提供了以下属性: + +| 属性 | 解释 | +| :--------------------------------------------------------------------------------------------- | :--------------------- | +| resource | Spine 动画资源。设置了资源后,SpineAnimationRenderer 组件会读取资源数据,并渲染出 Spine 动画 | +| setting | 渲染设置。用于控制开启裁减和调整图层间隔 | +| defaultState | 默认状态。与编辑器的配置项对应,用于设置默认状态下 Spine 动画的动画,皮肤,缩放 | +| state | 动画状态对象。用于进行更加复杂动画控制,如:队列播放,循环控制等 | +| skeleton | 骨架对象。用于进行更加复杂的骨架操作,如:附件替换,换肤等 | 下面是更详细的使用介绍: ### 资源设置 -首先是资源的设置。Spine 组件需要设置资源后,才能完成 Spine动画的渲染。在上一个章节,「加载资产并添加至场景」中,已经为大家展示了设置资产的方式: +首先是资源的设置。SpineAnimationRenderer 组件需要设置资源后,才能完成 Spine动画的渲染。在上一个章节,「加载资产并添加至场景」中,已经为大家展示了设置资产的方式: ```typescript import { SpineAnimationRenderer } from '@galacean/engine-spine'; @@ -212,7 +215,7 @@ class YourAmazingScript { } ``` -注意:默认状态仅在 Spine 组件激活和资源设置时生效。动态修改动画、皮肤、缩放请使用 state 与 skeleton 属性中的方法(见下面的章节)。 +注意:默认状态仅在 SpineAnimationRenderer 组件激活和资源设置时生效。动态修改动画、皮肤、缩放请使用 state 与 skeleton 属性中的方法(见下面的章节)。 ### 动画控制