Compare commits

...
65 Commits
Author SHA1 Message Date
Yeomai 98f44890f8 Revert "编译windows版本连接器"
This reverts commit 97e6713cfd.
2026-11-07 22:52:01 +08:00
Yeomai 97e6713cfd 编译windows版本连接器 2026-11-07 22:49:13 +08:00
tianmo e626e685fc 修正 2026-11-07 15:12:13 +08:00
tianmo b3e9481fe5 开关设置 2026-11-05 18:04:57 +08:00
tianmo 98e5c435c8 更新版本记录 2026-11-05 17:39:39 +08:00
tianmo 8977463861 跟随导出效果 2026-11-04 17:45:21 +08:00
tianmo 517e92a00b 公告栏修正 2026-11-04 11:15:47 +08:00
tianmo 87d6561779 公告栏 2026-11-04 10:55:40 +08:00
tianmo 720e3e0ceb 俄语支持 2026-11-04 10:21:57 +08:00
tianmo 753e3b1e21 网页优化 2026-10-31 15:26:31 +08:00
tianmo ab32146e8d 新增欢迎页 2026-10-31 11:48:29 +08:00
tianmo a11d57f27f 默认序列帧修改 2026-10-30 19:27:27 +08:00
tianmo 5efee6d918 资源整理 2026-10-30 19:10:29 +08:00
tianmo 7062a60c73 显示修正 2026-10-29 18:49:55 +08:00
tianmo bae179eb93 功能修复 2026-10-29 18:43:11 +08:00
tianmo fed1015849 优化碰撞识别,加入暖场帧数 2026-10-29 17:20:05 +08:00
tianmo b8652acce0 增加报表 2026-10-28 16:13:18 +08:00
tianmo ca719ba1d4 加入追踪 2026-10-28 14:32:52 +08:00
tianmo 859c78173a 加入网站追踪 2026-10-28 14:26:04 +08:00
tianmo c93ec671da 混淆
使用 javascript-obfuscator 做标识符重命名、字符串数组编码和常量拆分。
在 vite build 后增加生产环境混淆步骤,开发环境保持源码可读。
关闭 sourcemap,避免直接暴露原始源码。
当前主包约 1.7 MB,建议先拆出第三方依赖,再只混淆应用自身代码,而不是整个 dist。
2026-10-27 16:40:33 +08:00
tianmo 5f7e30063b 位置修正 2026-10-27 16:06:22 +08:00
tianmo 67bf3921cc 收缩按钮 2026-10-27 16:02:18 +08:00
tianmo 0f578d29a4 独立显示修正 2026-10-26 10:26:17 +08:00
tianmo 7069b168de 面板布局更新 2026-10-24 19:30:32 +08:00
tianmo a417c9119d 悬浮导航 2026-10-24 18:52:25 +08:00
tianmo cffbd49eff 体积显示 2026-10-23 11:55:40 +08:00
tianmo 46083c8d2a 完善浏览器配置库与未保存提示
- 新增 IndexedDB 配置保存、加载、删除及批量导入导出

- 配置列表支持选中、双击加载和悬浮操作提示

- 新增未保存提示设置,默认关闭并持久化到浏览器

- 优化配置管理界面并同步九种语言文案

- 更新开发文档与生产构建产物
2026-10-23 11:48:21 +08:00
tianmo 8bd9154414 多语言调整 2026-10-22 18:51:26 +08:00
tianmo 04de5e5a8e 多国语言支持 2026-10-22 18:37:30 +08:00
tianmo b98c0333e6 中英双语 2026-10-22 18:13:43 +08:00
tianmo ea26af26ee 文档完善 2026-10-22 17:23:38 +08:00
tianmo 5d12bf895b 修复导出问题 2026-10-22 17:19:09 +08:00
tianmo d224939efd 子发射器 2026-10-22 16:49:53 +08:00
tianmo d4ebb18777 新编译 2026-10-22 14:14:43 +08:00
tianmo 2b8fd067b3 轴点功能修复 2026-10-22 12:04:29 +08:00
tianmo 4d54c87681 贴图轴点 2026-10-22 11:46:00 +08:00
tianmo c041503004 渲染顺序功能 2026-10-22 11:16:50 +08:00
tianmo aabf4ff693 修改粒子命名规则 2026-10-14 18:35:26 +08:00
tianmo 1ecbb6fc48 飞牛部署文档 2026-10-10 15:35:51 +08:00
tianmo 61b93f590c 新增飞牛 fpk 打包部署文档与 1.0.2 安装包
- Document/飞牛fpk打包与部署.md:记录 fnpack 准备、改版本号、npm run pack:fnos 打包、安装到 fnOS、装完必查项、常见排障与上架前待办;并记下入口形态从 CGI 改为端口服务的原因(CGI 入口在目标设备上桌面图标不注册、「打开」无反应)
- release/fnos/SpineParticle-1.0.2.fpk:端口服务形态安装包,service_port=27850、依赖 nodejs_v22,桌面图标与「打开」正常,包内自带 Windows/macOS 连接器下载物
- 打包用源码暂未入库,仅保留安装包与文档

影响面:飞牛端新增应用包与文档,网页端代码未改动。
2026-10-10 15:27:56 +08:00
tianmo 7745f0de1b Merge branch 'main' of http://gitlab.yeomai.site/Yeomai/SpineParticlesWeb 2026-10-09 17:23:01 +08:00
tianmo 2ba2b9c08a 时间轴交互与预览修正
- 界面文字全局禁止鼠标框选,输入类控件保留可选中
- 拖动时间轴立即暂停自动播放,避免与播放头抢时间轴
- 暂停时补齐未完成的帧缓存,拖动时间轴直接读取已烘焙帧,无需重新触发播放
- 循环持续的烘焙不再依赖从第 0 帧开始录制,避免整段缓存为空
2026-10-09 17:22:36 +08:00
tianmo 0f08224e1c Changes to be committed:
modified:   src/style.css
	modified:   src/views/Stage.vue
	modified:   src/views/Timeline.vue
2026-10-09 17:21:34 +08:00
tianmo d6232c9d66 修正时间轴末帧,导出补阶跃与循环段边界关键帧
- 时间轴:末帧按时长实际覆盖的整数帧计算,修掉 Spine 时长小数舍入多出的一帧,B 端与回放自动停在最后一帧
- 导出:粒子死亡、骨骼池复用、拖尾假死复活处统一写 curve: stepped 硬切,避免死区插值出滑动轨迹
- 导出:循环持续在开始/循环/结束段边界(如 30f/110f)对所有动画通道补关键帧,便于在 Spine 中直接拆分
- 精简器支持阶跃段,阶跃区间内不再补出过渡关键帧
- 补充 test:spine-json 回归用例:骨骼池复用阶跃边界、循环段边界
2026-10-09 17:05:53 +08:00
tianmo f249c019bc 资源整理 2026-09-22 17:30:38 +08:00
tianmo f54e90c21a 预设修改 2026-09-22 17:21:49 +08:00
tianmo abb4ac4acc 预设修改 2026-09-22 17:17:39 +08:00
tianmo 4f2c980bac 修改预设,力场形状显示开关 2026-09-22 16:58:44 +08:00
tianmo 5f3a4a33fe 增加涡流修改器 2026-09-22 15:58:28 +08:00
tianmo 3f2699a946 添加形状显示开关,修改默认参数 2026-09-22 14:42:21 +08:00
tianmo aadcf5cbcf 增加预设 2026-09-22 14:26:40 +08:00
tianmo bf78477a66 Merge branch 'main' of http://gitlab.yeomai.site/Yeomai/SpineParticlesWeb 2026-09-14 17:30:49 +08:00
tianmo d629443ac1 预设修改 2026-09-14 17:30:29 +08:00
Yeomai 32a594dd8a 帮助说明修改 2026-09-13 00:34:44 +08:00
tianmo 6de6e2cf5b 导出修正 2026-09-12 12:17:26 +08:00
tianmo ee72d09b23 代码拆分 2026-09-12 11:53:12 +08:00
tianmo 8bf16fe09f 通用架构安装包 2026-09-11 10:09:45 +08:00
tianmo 83890faaea 资源拆分 2026-09-11 10:00:38 +08:00
tianmo f810e712ec Merge branch 'main' of http://gitlab.yeomai.site/Yeomai/SpineParticlesWeb 2026-09-11 10:00:19 +08:00
tianmo 8ab3672c4e 粒子连接器修改 2026-09-11 10:00:16 +08:00
Yeomai 5efc608835 windows发行版 2026-09-08 00:09:17 +08:00
Yeomai bd0771747d 新增windows支持 2026-09-07 23:48:20 +08:00
tianmo 6837ecada9 加入5套spine官方运行时 2026-09-07 21:40:29 +08:00
tianmo 98e51a259e Merge branch 'main' of http://gitlab.yeomai.site/Yeomai/SpineParticlesWeb 2026-09-07 21:29:21 +08:00
tianmo 3a7c0b4092 导出数据转spine 2026-09-07 21:29:15 +08:00
1340 changed files with 208669 additions and 13092 deletions

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
/build/
/.cache/
/tests/fixtures/external/
*.user
*.swp
.DS_Store
+140
View File
@@ -0,0 +1,140 @@
cmake_minimum_required(VERSION 3.25)
project(DateToSpine VERSION 0.1.0 LANGUAGES CXX)
include(cmake/OfflineDependencies.cmake)
option(DTS_BUILD_TESTS "Build DateToSpine tests" ON)
option(DTS_BUILD_GUI "Build the DateToSpine desktop application" ON)
option(DTS_BUILD_RUNTIME_PLUGINS "Build bundled Spine runtime adapters" ON)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
add_library(dts_core
src/domain/SpineVersion.cpp
src/atlas/AtlasParser.cpp
src/preview/RuntimePlugin.cpp
src/scan/SpineDataVersionDetector.cpp
src/editor/SpineEditorLocator.cpp
src/editor/SpineOutputParser.cpp
src/editor/SpineProbe.cpp
src/editor/SpineVersionAvailability.cpp
)
if(APPLE OR UNIX)
target_sources(dts_core PRIVATE src/platform/ProcessRunner_posix.cpp)
elseif(WIN32)
target_sources(dts_core PRIVATE src/platform/ProcessRunner_windows.cpp)
else()
message(FATAL_ERROR "Unsupported platform")
endif()
target_include_directories(dts_core PUBLIC src runtime-api)
target_link_libraries(dts_core PRIVATE ${CMAKE_DL_LIBS})
if(MSVC)
target_compile_options(dts_core PRIVATE /W4 /permissive- /utf-8)
else()
target_compile_options(dts_core PRIVATE -Wall -Wextra -Wpedantic)
endif()
if(DTS_BUILD_RUNTIME_PLUGINS)
include(cmake/SpineRuntime.cmake)
dts_add_runtime_adapter(38 "3.8" spine38 "spine-cpp/spine-cpp")
dts_add_runtime_adapter(40 "4.0" spine40 "spine-cpp/spine-cpp")
dts_add_runtime_adapter(41 "4.1" spine41 "spine-cpp/spine-cpp")
dts_add_runtime_adapter(42 "4.2" spine42 "spine-cpp/spine-cpp")
dts_add_runtime_adapter(43 "4.3" spine43 "spine-cpp")
endif()
add_executable(datetospine-cli src/app/cli_main.cpp)
target_link_libraries(datetospine-cli PRIVATE dts_core)
if(DTS_BUILD_GUI)
find_package(Qt6 REQUIRED COMPONENTS Concurrent OpenGLWidgets Widgets)
add_executable(DateToSpine MACOSX_BUNDLE WIN32
src/app/gui_main.cpp
src/conversion/ConversionJob.cpp
src/conversion/ConversionJob.hpp
src/ui/MainWindow.cpp
src/ui/MainWindow.hpp
src/ui/SpinePreviewWidget.cpp
src/ui/SpinePreviewWidget.hpp
)
set_target_properties(DateToSpine PROPERTIES
AUTOMOC ON
MACOSX_BUNDLE_GUI_IDENTIFIER io.datetospine.desktop
MACOSX_BUNDLE_BUNDLE_NAME DateToSpine)
target_link_libraries(DateToSpine PRIVATE
dts_core
Qt6::Concurrent
Qt6::OpenGLWidgets
Qt6::Widgets
)
if(DTS_BUILD_RUNTIME_PLUGINS)
add_dependencies(DateToSpine
dts_runtime_38 dts_runtime_40 dts_runtime_41 dts_runtime_42 dts_runtime_43)
# Runtime plugin destination differs by platform:
# - macOS: inside the .app bundle (Contents/PlugIns/runtimes)
# - Windows/other: alongside the executable
if(APPLE)
set(DTS_RUNTIME_PLUGIN_DIR "$<TARGET_BUNDLE_DIR:DateToSpine>/Contents/PlugIns/runtimes")
else()
set(DTS_RUNTIME_PLUGIN_DIR "$<TARGET_FILE_DIR:DateToSpine>/runtimes")
endif()
foreach(runtime_line IN ITEMS 38 40 41 42 43)
add_custom_command(TARGET dts_runtime_${runtime_line} POST_BUILD
COMMAND ${CMAKE_COMMAND} -E make_directory
"${DTS_RUNTIME_PLUGIN_DIR}"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"$<TARGET_FILE:dts_runtime_${runtime_line}>"
"${DTS_RUNTIME_PLUGIN_DIR}/")
add_custom_command(TARGET DateToSpine POST_BUILD
COMMAND ${CMAKE_COMMAND} -E make_directory
"${DTS_RUNTIME_PLUGIN_DIR}"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"$<TARGET_FILE:dts_runtime_${runtime_line}>"
"${DTS_RUNTIME_PLUGIN_DIR}/")
endforeach()
endif()
endif()
if(DTS_BUILD_TESTS)
enable_testing()
add_executable(dts_core_tests
tests/unit/CoreTests.cpp
)
target_link_libraries(dts_core_tests PRIVATE dts_core)
if(DTS_BUILD_RUNTIME_PLUGINS)
add_dependencies(dts_core_tests
dts_runtime_38 dts_runtime_40 dts_runtime_41 dts_runtime_42 dts_runtime_43)
target_compile_definitions(dts_core_tests PRIVATE
DTS_RUNTIME_38_PATH="$<TARGET_FILE:dts_runtime_38>"
DTS_RUNTIME_40_PATH="$<TARGET_FILE:dts_runtime_40>"
DTS_RUNTIME_41_PATH="$<TARGET_FILE:dts_runtime_41>"
DTS_RUNTIME_42_PATH="$<TARGET_FILE:dts_runtime_42>"
DTS_RUNTIME_43_PATH="$<TARGET_FILE:dts_runtime_43>"
DTS_TEST_38_JSON="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/3.8/examples/spineboy/export/spineboy-pro.json"
DTS_TEST_38_SKEL="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/3.8/examples/spineboy/export/spineboy-pro.skel"
DTS_TEST_38_ATLAS="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/3.8/examples/spineboy/export/spineboy.atlas"
DTS_TEST_40_JSON="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.0/examples/spineboy/export/spineboy-pro.json"
DTS_TEST_40_SKEL="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.0/examples/spineboy/export/spineboy-pro.skel"
DTS_TEST_40_ATLAS="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.0/examples/spineboy/export/spineboy.atlas"
DTS_TEST_41_JSON="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.1/examples/spineboy/export/spineboy-pro.json"
DTS_TEST_41_SKEL="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.1/examples/spineboy/export/spineboy-pro.skel"
DTS_TEST_41_ATLAS="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.1/examples/spineboy/export/spineboy.atlas"
DTS_TEST_42_JSON="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.2/examples/spineboy/export/spineboy-pro.json"
DTS_TEST_42_SKEL="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.2/examples/spineboy/export/spineboy-pro.skel"
DTS_TEST_42_ATLAS="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.2/examples/spineboy/export/spineboy.atlas"
DTS_TEST_SPINEBOY_JSON="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.3/examples/spineboy/export/spineboy-pro.json"
DTS_TEST_SPINEBOY_SKEL="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.3/examples/spineboy/export/spineboy-pro.skel"
DTS_TEST_SPINEBOY_ATLAS="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.3/examples/spineboy/export/spineboy.atlas")
endif()
add_test(NAME dts_core_tests COMMAND dts_core_tests)
endif()
install(FILES third_party/spine-runtimes/4.3/spine-cpp/LICENSE
DESTINATION licenses
RENAME Spine-Runtimes-License.txt)
+65
View File
@@ -0,0 +1,65 @@
{
"version": 6,
"cmakeMinimumRequired": {
"major": 3,
"minor": 25,
"patch": 0
},
"configurePresets": [
{
"name": "macos-debug",
"displayName": "macOS Debug",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/macos-debug",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Debug",
"DTS_BUILD_TESTS": "ON"
},
"condition": {
"type": "equals",
"lhs": "${hostSystemName}",
"rhs": "Darwin"
}
},
{
"name": "windows-msvc-debug",
"displayName": "Windows MSVC Debug",
"generator": "Visual Studio 17 2022",
"architecture": "x64",
"binaryDir": "${sourceDir}/build/windows-msvc-debug",
"cacheVariables": {
"DTS_BUILD_TESTS": "ON"
},
"condition": {
"type": "equals",
"lhs": "${hostSystemName}",
"rhs": "Windows"
}
}
],
"buildPresets": [
{
"name": "macos-debug",
"configurePreset": "macos-debug"
},
{
"name": "windows-msvc-debug",
"configurePreset": "windows-msvc-debug",
"configuration": "Debug"
}
],
"testPresets": [
{
"name": "macos-debug",
"configurePreset": "macos-debug",
"output": { "outputOnFailure": true }
},
{
"name": "windows-msvc-debug",
"configurePreset": "windows-msvc-debug",
"configuration": "Debug",
"output": { "outputOnFailure": true }
}
]
}
@@ -0,0 +1,28 @@
# DateToSpine is distributed as a source-complete offline project. Configuration
# must fail when a vendored dependency is missing; it must never fetch one.
set(FETCHCONTENT_FULLY_DISCONNECTED ON CACHE BOOL
"Never download dependencies while configuring DateToSpine" FORCE)
set(FETCHCONTENT_UPDATES_DISCONNECTED ON CACHE BOOL
"Never update dependencies while configuring DateToSpine" FORCE)
function(dts_require_vendored_spine version_line runtime_subdirectory)
set(version_root
"${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/${version_line}")
set(runtime_root "${version_root}/${runtime_subdirectory}")
foreach(required_file IN ITEMS
"${version_root}/LICENSE"
"${runtime_root}/include/spine/spine.h")
if(NOT EXISTS "${required_file}")
message(FATAL_ERROR
"Offline dependency is incomplete: ${required_file}\n"
"Restore the vendored files from the DateToSpine source package. "
"CMake will not download them.")
endif()
endforeach()
endfunction()
dts_require_vendored_spine("3.8" "spine-cpp/spine-cpp")
dts_require_vendored_spine("4.0" "spine-cpp/spine-cpp")
dts_require_vendored_spine("4.1" "spine-cpp/spine-cpp")
dts_require_vendored_spine("4.2" "spine-cpp/spine-cpp")
dts_require_vendored_spine("4.3" "spine-cpp")
+37
View File
@@ -0,0 +1,37 @@
function(dts_add_spine_runtime target runtime_root)
if(NOT EXISTS "${runtime_root}/include/spine/spine.h")
message(FATAL_ERROR "Missing spine-cpp source at ${runtime_root}")
endif()
file(GLOB_RECURSE runtime_sources CONFIGURE_DEPENDS
"${runtime_root}/src/*.cpp")
file(GLOB_RECURSE runtime_headers CONFIGURE_DEPENDS
"${runtime_root}/include/*.h")
add_library(${target} STATIC ${runtime_sources} ${runtime_headers})
target_include_directories(${target} PUBLIC "${runtime_root}/include")
set_target_properties(${target} PROPERTIES POSITION_INDEPENDENT_CODE ON)
if(MSVC)
target_compile_options(${target} PRIVATE /W3 /utf-8)
else()
target_compile_options(${target} PRIVATE -Wno-deprecated-declarations)
endif()
endfunction()
function(dts_add_runtime_adapter suffix version_line source_directory runtime_subdirectory)
set(runtime_root
"${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/${version_line}/${runtime_subdirectory}")
set(runtime_target "dts_spine_cpp_${suffix}")
set(adapter_target "dts_runtime_${suffix}")
dts_add_spine_runtime(${runtime_target} "${runtime_root}")
add_library(${adapter_target} SHARED
"${CMAKE_CURRENT_SOURCE_DIR}/runtime-plugins/${source_directory}/RuntimeAdapter.cpp")
target_include_directories(${adapter_target} PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}/runtime-api")
target_link_libraries(${adapter_target} PRIVATE ${runtime_target})
set_target_properties(${adapter_target} PROPERTIES
CXX_VISIBILITY_PRESET hidden
VISIBILITY_INLINES_HIDDEN YES
OUTPUT_NAME "dts-runtime-${version_line}")
endfunction()
+146
View File
@@ -0,0 +1,146 @@
# 产品需求
## 1. 产品目标
DateToSpine 帮助用户批量处理仅保留运行时导出文件、但缺失原始 `.spine` 工程的资源。软件需要在转换前验证资源并显示可交互动画预览,用户确认后才调用本地 Spine Editor 生成可编辑工程。
产品目标是“尽可能恢复可编辑工程”,不是承诺字节级还原原工程。运行时导出时已经丢失的数据无法凭空恢复。
## 2. 用户前提
- 用户对待处理资源拥有合法使用和转换权限。
- 用户电脑已安装并激活 Spine Editor。
- 需要生成 `.spine` 时,本地已有与资源主/次版本相匹配的 Editor 版本。
- 未安装 Editor 时,软件仍可扫描、校验、配对和预览,但不能生成 `.spine`。
## 3. 支持范围
### 3.1 骨骼数据
| 格式 | 支持状态 | 说明 |
|---|---|---|
| `.json` | 正式支持 | 必须通过 Spine schema 识别,不能把普通 JSON 当作骨骼数据 |
| `.skel` | 正式支持 | 按导出版本选择匹配 Runtime 和 Editor |
| `.skel.bytes` | 候选兼容 | 通过内容识别,扩展名支持在技术原型后决定 |
### 3.2 Spine 版本
| 版本 | 支持级别 |
|---|---|
| 3.8.20+ | 正式支持 |
| 3.8.0—3.8.19 | 尽力识别,不作正式保证 |
| 4.0.x | 正式支持 |
| 4.1.x | 正式支持 |
| 4.2.x | 正式支持 |
| 4.3.x | 正式支持 |
| 3.7.x 及更早 | 不支持 |
### 3.3 Atlas 与纹理页
- `.atlas`、`.atlas.txt`
- 单页和多页 Atlas
- PNG
- JPG/JPEG
- WebP
- 同一 Atlas 中不同扩展名的多页纹理
- Straight Alpha 和 Premultiplied Alpha
其他图片格式通过可扩展解码接口保留未来支持空间,但不属于首版承诺。
## 4. 主用户流程
主界面只暴露四个动作:
1. 用户把完整导出资源或资源文件夹拖入窗口。
2. 软件自动配对并显示预览;有多套资源时用顶部下拉框切换。
3. 用户用底部下拉框切换动画。
4. 用户点击“一键转换为 .spine”并选择保存位置。
版本检测、Atlas 配对、图片处理、Editor 验证和结果校验均在后台自动完成。只有自动找不到 Spine Editor 时,才要求用户指定 Spine 应用或可执行文件路径。普通流程不提供 Editor 管理页或技术参数面板。
## 5. 功能需求
### 5.1 扫描与配对
- 支持文件、文件夹和批量拖放。
- 支持递归扫描,允许配置最大深度。
- 根据 Atlas 中声明的页面名查找纹理,不能只依赖同名前缀。
- 根据附件 path、文件名、目录距离和名称前缀对数据与 Atlas 评分。
- 自动结果必须显示置信度。
- 歧义配对必须由用户确认,不能静默选择。
- 单个失败组不能终止整个批次。
### 5.2 预览
- 默认循环播放,支持动画切换。
- 画布自动适应窗口。
- 支持 Mesh、权重、Clipping、双颜色 Tint 和四种混合模式。
- 显示缺失纹理、版本错误和 Runtime 加载错误。
### 5.3 Editor 管理
- 自动查找常见安装位置。
- 自动查找失败时才显示路径选择器,支持自定义应用、可执行文件或安装目录。
- 支持一个安装入口对应多个本地可用版本。
- 转换前执行实际版本探测。
- 不读取、存储或传输 Spine 激活码。
### 5.4 批量转换
- 默认串行调用 Spine Editor,扫描和静态校验可并行。
- 支持停止等待中的任务和取消当前子进程。
- 支持跳过、自动重命名和覆盖策略。
- 默认不覆盖输入文件。
- 正式结果验证通过前只写入任务临时目录。
- 每组资源产生可读日志和机器可读报告。
## 6. 恢复能力边界
通常可恢复:骨骼、Slots、Skins、Attachments、约束、动画、Deform、事件和绘制顺序。
如果源文件导出时未启用 Nonessential data,可能永久缺失:
- 骨骼颜色和图标
- Mesh 手工边
- 编辑器辅助信息
- 部分附件原始尺寸
- 原工程图片和音频目录设置
- 编辑器视图与工作区状态
报告必须区分:
- 完整恢复
- 主体恢复但缺少 Nonessential data
- 工程生成成功但图片关联需要注意
- 转换失败
## 7. 非功能需求
- macOS 和 Windows 功能一致。
- 运行时完全离线,不依赖 CDN 或在线服务。
- 支持中文、空格、Unicode 和较长路径。
- 所有外部进程使用参数数组启动,不能拼接 shell 命令。
- 崩溃或取消不能留下半成品覆盖正式结果。
- 日志不能包含用户授权信息或不必要的绝对路径。
- 普通操作错误必须以用户可理解的语言呈现。
## 8. 首版不包含
- Spine 3.7 及更早版本。
- 绕过 Spine Editor 授权或下载 Editor。
- 自行生成或逆向写入 `.spine` 格式。
- 动画编辑功能。
- 云端转换、资源上传或账号系统。
- 将官方示例资产打包给最终用户。
## 9. 产品验收标准
- 五个目标版本均可完成 JSON、SKEL 的预览和转换测试。
- 断网环境可完成已准备版本的完整流程。
- PNG、JPG/JPEG、WebP 页面可预览。
- 纹理只允许使用匹配版本 Spine Editor 的官方 Texture Unpacker 解包。
- 生成物能被匹配版本 Editor 重新读取。
- 骨骼、Skin、附件、动画和事件数量通过语义核对。
- 原始输入不被修改。
- 单任务失败不影响批次其他任务。
- 最终安装包不含官方测试资源和 Spine Editor。
+219
View File
@@ -0,0 +1,219 @@
# 系统架构
## 1. 技术基线
| 项目 | 选择 |
|---|---|
| 语言 | C++20(第三方 Runtime target 可使用其原生标准) |
| UI | Qt 6 Widgets |
| 预览画布 | `QOpenGLWidget` + 自研批次渲染器 |
| 构建 | CMake 3.25+、CMake Presets |
| Windows 编译器 | Visual Studio 2022 / MSVC v143 |
| macOS 编译器 | Apple Clang |
| 进程管理 | `QProcess` |
| 设置存储 | `QSettings` |
| JSON | Qt JSON |
| 图片加载 | Qt Image plugins,随程序离线部署 |
| 测试 | Qt Test 或 Catch2,原型结束时二选一 |
CMake 只用于开发构建,不是最终用户的运行依赖。Windows 可以使用 Visual Studio Generator 或 Ninja + MSVC。
五个版本的官方 `spine-cpp` 以普通 vendor 源码纳入主仓库,不使用 Git Submodule。CMake 配置启用完全断开模式并检查必要头文件与许可证;缺失时明确失败,绝不执行依赖下载。这样内网只需克隆一次主仓库即可构建。
## 2. 分层结构
```text
UI
├─ 资源组表格
├─ 预览与播放控制
├─ Editor 管理
└─ 任务、日志与报告
↓
Application Services
├─ ScanService
├─ PreviewService
├─ EditorRegistry
├─ RecoveryCoordinator
└─ ReportService
↓
Domain
├─ AssetGroup
├─ SpineVersion
├─ EditorInstallation
├─ RecoveryJob
└─ RecoveryResult
↓
Infrastructure
├─ Filesystem / Process
├─ Runtime Plugin Host
├─ Spine CLI Adapter
├─ Atlas Unpackers
└─ Output Publisher
```
UI 只表达状态和用户意图,不能直接调用 Spine CLI 或解析 Atlas。
## 3. 建议目录
```text
DateToSpine/
CMakeLists.txt
CMakePresets.json
cmake/
docs/
licenses/
resources/
icons/
shaders/
translations/
src/
app/
domain/
scan/
atlas/
editor/
preview/
recovery/
report/
platform/
macos/
windows/
ui/
util/
runtime-api/
runtime-plugins/
spine38/
spine40/
spine41/
spine42/
spine43/
third_party/
spine-runtimes/
licenses/
tests/
unit/
integration/
compatibility/
manifests/
fixtures/
generated/
external/
packaging/
macos/
windows/
```
`tests/fixtures/external/` 用于本地准备的官方测试资源,不进入发行包。
## 4. 多版本 Runtime 隔离
五套 Runtime 不能直接链接进同一个主程序命名空间。每个版本编译成独立动态模块,并隐藏所有上游 C++ 符号,只导出稳定的 DateToSpine C ABI。
```text
dts-runtime-3_8
dts-runtime-4_0
dts-runtime-4_1
dts-runtime-4_2
dts-runtime-4_3
```
公共 ABI 只允许:
- 固定宽度整数
- POD 结构体
- UTF-8 字节串
- 不透明句柄
- 显式长度的数组
- 函数指针回调
禁止跨模块传递 Qt 对象、STL 容器、异常或 Spine Runtime 类。
每个适配模块承担:
- 对应版本 JSON/SKEL 读取
- Atlas 绑定
- 动画状态推进
- Skin/Animation 查询和切换
- 把绘制结果转换为统一顶点、索引和 draw command
- 把上游错误转换为统一错误码
## 5. 预览渲染边界
Runtime 插件负责骨骼计算和裁剪;主程序渲染器负责:
- 纹理创建和缓存
- 顶点/索引上传
- Shader
- Blend mode
- 相机、背景和调试覆盖层
- DPI 和窗口生命周期
Runtime 不直接创建 Qt 或 OpenGL 对象。这使 Runtime 适配可独立测试,也避免上下文所有权问题。
## 6. Spine CLI 边界
所有 Editor 调用必须经过 `ISpineCli`:
```text
probe installation
probe version
import data
export normalized json
unpack atlas
inspect project
```
实现要求:
- 使用 `QProcess::setProgram` 和 `setArguments`。
- 不经过 shell。
- 每次调用有超时、取消令牌、工作目录和独立日志。
- 只终止本软件启动的子进程。
- 对 Windows 优先选择 `Spine.com`。
- 对 macOS 把 `.app` 规范化为内部可执行文件。
## 7. 任务并发
- 扫描、哈希、图片头校验可以在线程池执行。
- OpenGL 资源操作只在所属渲染线程执行。
- Spine Editor 调用默认全局串行。
- Spine 官方 Atlas 解包与工程导入全局串行,并受任务取消状态约束。
- 每个恢复任务有独立临时目录和取消状态。
## 8. 文件长度和复杂度约束
自研代码执行以下软上限,超过时必须在评审中说明或拆分:
| 文件类型 | 建议上限 |
|---|---:|
| 头文件 | 200 行 |
| 普通 `.cpp` | 350 行 |
| UI `.cpp` | 450 行 |
| 单元测试文件 | 500 行 |
同时执行:
- 一个类只有一个主要职责。
- `MainWindow` 不包含解析、转换和进程逻辑。
- 禁止无边界的 `Utils` 类。
- 平台代码不得散落到业务模块。
- 版本差异留在 Runtime adapter 和 version policy 中。
- 第三方上游源码保持原样,位于 `third_party/`,不纳入自研文件行数限制。
CI 将检查自研源文件行数、循环依赖和禁止目录引用。
## 9. 可测试性
核心依赖使用小接口注入:
```text
IFileSystem
IProcessRunner
ISpineCli
IRuntimePlugin
IAtlasUnpacker
IClock
IReportSink
```
单元测试不启动真实 Editor;集成和兼容测试才调用本地 Spine Editor。
@@ -0,0 +1,193 @@
# 版本识别与 Spine Editor 检测
## 1. 目标
在用户确认转换前,软件必须回答:
1. 资源由哪个 Spine 主/次版本导出?
2. 本机是否有可以处理该版本的 Spine Editor?
3. 配置路径是否指向可用的 Spine CLI?
4. 在断网状态下,该版本是否能实际启动和导入?
路径存在不等于 Editor 可用。当前实现先读取本机 Spine 更新缓存,只把已经存在的精确版本视为可转换;转换时再次执行同一门禁。
## 2. 资源版本识别
### 2.1 JSON
读取 `skeleton.spine` 字段,并同时验证最小 Spine schema:
- 根对象
- `bones` 数组
- 可选 `slots`、`skins`、`animations`
- 版本字段格式
只解析识别所需的小范围数据。无 Spine schema 的业务 JSON 必须忽略。
### 2.2 SKEL
识别顺序:
1. 对二进制头执行有界读取,尝试提取 hash 和版本字符串。
2. 用候选版本 Runtime 插件执行只读探测。
3. 必要时用已配置 Editor 的 info 命令验证。
4. 多个版本都失败则要求用户选择版本,并标记为未确认。
解析必须限制字符串长度、数组大小和文件大小,损坏文件不能导致无限分配。
## 3. Editor 数据模型
```text
EditorInstallation
id
displayName
platform
userSelectedPath
canonicalExecutable
discoverySource
lastVerifiedAt
baseVersion
signatureStatus
capabilities[]
EditorCapability
versionLine // 3.8, 4.0, 4.1, 4.2, 4.3
launchSelector // 可选 -u 参数或独立 executable
status
lastProbeResult
```
同一个安装入口可以声明多个本地版本能力;多个安装入口也可以映射到同一版本。用户可为每个版本选择首选项。
## 4. 自动发现
### 4.1 macOS
优先位置:
```text
/Applications/Spine.app
~/Applications/Spine.app
```
补充来源:
- 系统应用索引
- 当前会话已知应用路径
- 历史用户配置
用户选择 `.app` 时规范化为:
```text
Spine.app/Contents/MacOS/Spine
```
不递归扫描整个磁盘。
### 4.2 Windows
发现来源:
- HKLM/HKCU 安装与卸载注册信息
- `Program Files`、`Program Files (x86)` 常见位置
- `PATH`
- 开始菜单快捷方式目标
- 历史用户配置
CLI 优先选择 `Spine.com`。如果用户选择 `Spine.exe`,查找同目录同名 `.com`;找不到时允许保存,但状态为“GUI 路径,CLI 待验证”。
## 5. 自定义路径
用户可选择:
- macOS `.app`
- 内部 Spine 可执行文件
- Windows `Spine.com` 或 `Spine.exe`
- 包含上述文件的目录
保存前执行路径规范化:
- 解析相对路径
- 清理多余分隔符
- 保留用户显示路径
- 保存 canonical path
- 记录文件标识和修改时间,用于发现安装被替换
路径移动或升级后,不自动删除旧配置,而是标记失效并允许重新定位。
## 6. 验证步骤
### 6.1 静态验证
- 文件存在且不是目录。
- 当前用户可执行。
- macOS bundle 结构或 Windows 同目录文件合理。
- 可选检查平台签名;旧版签名异常只警告,不直接否定合法安装。
### 6.2 基础进程验证
使用参数数组执行 `--version`,捕获:
- 退出码
- 标准输出和错误输出
- 启动耗时
- 报告版本
设置短超时,只终止该次探测进程。
### 6.3 版本能力验证
对用户配置的目标版本执行实际启动探测。若使用 `-u <version>` 选择器:
- 只允许已确认在本地缓存中存在的精确版本,例如资源线为 4.3 时选择本地最新的 `4.3.23`。
- 不使用 `--force`。
- 不使用 `latest`、`stable`、`beta` 或 `x.x.xx` 版本选择器。
- 不由 DateToSpine 发起下载。
- 缓存缺失时不启动 Spine Launcher,直接报告“本地未缓存目标版本”,从源头避免 Launcher 下载。
- 启动进程前再次检查精确缓存文件,避免检测后被移动导致意外联网。
当前 macOS 检查 `~/Library/Application Support/Spine/updates`,Windows 检查 `%APPDATA%/Spine/updates` 和 `%LOCALAPPDATA%/Spine/updates`。后续若 Spine 改变缓存结构,需要同步更新并重新做断网回归。
### 6.4 导入冒烟测试
在用户点击“深度验证”或首次转换前,可在临时目录执行小型导入:
- 输入与版本匹配的最小测试数据。
- 输出到临时目录。
- 验证退出码和 `.spine` 可再次读取。
- 完成立即清理临时结果。
发行版不能使用未获再分发确认的官方测试资产作为内置冒烟样本;需要自有最小样本或仅对用户当前资源执行验证。
## 7. 状态与用户提示
```text
Available
PathMissing
NotExecutable
NotSpine
CliCompanionMissing
VersionMismatch
VersionNotPrepared
NotActivated
ProbeTimedOut
PermissionDenied
LaunchFailed
Unknown
```
每个状态需要:
- 用户可读说明
- 技术详情
- 可执行的修复建议
- 是否允许预览
- 是否允许转换
资源全部载入后,主界面顶部直接显示当前资源的结果,例如“可转换 · Spine 4.3.23”或“缺少 Spine 4.1”。自动检测失败时,转换按钮才变为“指定 Spine 路径”。
## 8. 离线要求
DateToSpine 本身不联网。完整转换成立的前提是对应 Editor 版本已经在本机准备好并能在断网时运行。
离线验收必须在网络被禁用的独立测试环境进行,不能只通过代码审查推断。
+186
View File
@@ -0,0 +1,186 @@
# 恢复流水线
## 1. 状态机
```text
Discovered
→ Grouped
→ PreflightPassed
→ PreviewReady
→ UserConfirmed
→ EditorVerified
→ AtlasUnpacked
→ ProjectImported
→ ProjectVerified
→ Published
```
任一步骤失败进入 `Failed(stage, code, details)`。取消进入 `Cancelled`。失败与取消不能发布半成品。
## 2. 资源组
一个 `AssetGroup` 包含:
```text
id
skeletonDataPath
skeletonFormat
detectedVersion
atlasPath
atlasPages[]
candidateScore
scale
alphaMode
warnings[]
```
Atlas 页面名是纹理配对的权威来源。文件前缀只作为寻找数据与 Atlas 关系的辅助信号。
## 3. 预检
转换前检查:
- 数据格式和版本
- 版本是否在支持范围
- Atlas 基础语法
- 所有页面是否存在
- 图片解码和尺寸
- Atlas scale/PMA
- 输出命名冲突
- 目标 Editor 可用性
- 临时目录和输出目录可写性
- 磁盘空间粗略估计
预检不修改输入。
## 4. 用户确认边界
预览阶段不得生成正式 `.spine`。允许的临时操作仅包括:
- Runtime 加载
- 图片解码
- 静态校验
- Editor 只读版本探测
只有用户点击“开始转换”后,才能调用导入和解包命令。
## 5. 临时工作区
```text
job-<uuid>/
source-links/
normalized/
unpacked-images/
intermediate/
logs/
publish/
```
- 优先使用复制或只读访问,不能修改源文件。
- 是否允许安全硬链接作为优化,在跨平台验证后决定。
- 每个任务独立,名称使用内部 UUID,避免用户文件名注入路径。
## 6. Atlas 解包
1. 只调用匹配版本 Spine Editor 的官方 Texture Unpacker。
2. 校验页面、region 数量、输出尺寸和文件存在性。
3. 官方失败或验证失败时终止任务并显示原因,不使用其他解包实现。
4. 原始 `.atlas` 和打包纹理页只作为只读输入,不复制到最终结果。
5. 工程导入必须读取 Atlas 的 `scale` 并使用 `-s <数值>`,使骨骼与官方解包图片采用相同打包比例。
详细规则见 `06-图集解包设计.md`。
## 7. JSON 输入工程生成
1. 复制 JSON 到临时目录。
2. 保留所有未知字段。
3. 仅在必要时更新临时副本中的 `skeleton.images` 相对路径。
4. 使用匹配主/次版本的 Editor 导入。
5. 输出临时 `.spine`。
6. 使用 Editor info 或再次导出执行语义验证。
禁止修改源 JSON。
## 8. SKEL 输入工程生成
SKEL 的图片路径自动关联是技术原型重点。候选官方流程:
1. 匹配版本 Editor 把 SKEL 导入临时 `.spine`。
2. Editor 从临时工程导出规范化 JSON。
3. 在规范化 JSON 临时副本中设置 `skeleton.images`。
4. 匹配版本 Editor 再导入为最终候选 `.spine`。
5. 比较初次导入、规范化 JSON 和最终工程的语义计数。
此流程避免自行解析并序列化全部 SKEL,也避免修改未公开的 `.spine` 格式。
原型必须验证:
- 默认 JSON 导出是否保留全部运行时语义。
- 动画曲线、Deform、Linked Mesh、约束和事件是否无损。
- 图片相对路径是否被最终 `.spine` 保存。
- 3.8—4.3 是否行为一致。
若某版本失败,保留“工程 + images 目录 + 首次打开路径提示”作为降级方案,但正式自动化验收前必须由产品方确认是否接受。
## 9. Scale 与 Alpha
- Atlas 显式 `scale` 优先。
- 数据和 Atlas 都没有足够信息时,不猜测为确定值。
- 可提供 1、0.5、0.25 等候选并让用户预览比较。
- 用户可通过底部“去除预乘 Alpha”开关决定是否将解包图片转换为 Straight Alpha。
- 为保证各版本行为一致,官方解包使用临时 Atlas 副本并关闭其自动去预乘;开关开启后由本地像素处理统一完成转换。
- 临时 Atlas 不修改源文件,也不进入最终结果。
## 10. 结果验证
### 10.1 文件级
- `.spine` 存在且非空。
- 所有预期图片存在且可解码。
- 输出路径不逃逸目标目录。
- 临时文件未混入正式结果。
### 10.2 语义级
至少比较:
- 骨骼数和名称
- Slot 数和名称
- Skin 数和名称
- Attachment 类型与数量
- 约束类型与数量
- Animation 名称、数量和时长
- Event 名称和数量
- Deform timeline 存在性
Nonessential 缺失作为恢复等级,不作为运行时语义失败。
### 10.3 可读性
- 使用匹配 Editor 重新读取生成项目。
- 捕获所有 warning/error。
- 版本不匹配警告视为失败,除非用户启用实验模式。
## 11. 原子发布
验证通过后:
1. 在输出目录同一文件系统创建候选目录。
2. 应用重名策略。
3. 一次性重命名为正式结果。
4. 覆盖时先备份或移入可恢复位置。
5. 发布失败保留诊断,但不留下伪成功目录。
## 12. 输出结构
```text
Output/
hero/
hero.spine
images/
body.png
face/
eye.png
```
当前简化产品不输出原始 Atlas、打包纹理页、日志或恢复报告。发生错误时直接弹窗给出失败阶段和 Spine 输出摘要;成功时弹窗显示完整输出目录。
+156
View File
@@ -0,0 +1,156 @@
# 离线预览器
## 1. 目标
预览器在不调用 Spine Editor、不开启网络的情况下直接读取:
```text
.json/.skel + .atlas/.atlas.txt + texture pages
```
用户通过预览确认资源配对、版本、Scale、Alpha、动画和 Skin 后,才进入转换。
## 2. Runtime 版本矩阵
| 数据 | Runtime 插件 |
|---|---|
| 3.8.20+ | `dts-runtime-3_8` |
| 4.0.x | `dts-runtime-4_0` |
| 4.1.x | `dts-runtime-4_1` |
| 4.2.x | `dts-runtime-4_2` |
| 4.3.x | `dts-runtime-4_3` |
每个插件基于官方仓库对应分支的 `spine-cpp`。Runtime 与数据主/次版本必须一致。
## 3. 公共插件 ABI
公共 API 使用版本号和结构体大小实现向前兼容:
```text
dtsRuntimeGetApiVersion
dtsRuntimeCreate
dtsRuntimeDestroy
dtsRuntimeLoad
dtsRuntimeGetMetadata
dtsRuntimeGetAnimations
dtsRuntimeGetSkins
dtsRuntimeSetAnimation
dtsRuntimeSetSkin
dtsRuntimeSeek
dtsRuntimeUpdate
dtsRuntimeBuildFrame
dtsRuntimeGetLastError
```
设计规则:
- 调用方提供结构体 `size`。
- 插件返回的内存由插件释放。
- 错误不能以 C++ 异常越过 ABI。
- 所有字符串为 UTF-8,并带长度。
- 插件的所有上游符号默认隐藏。
- 主程序校验插件文件和 ABI 版本后才加载。
## 4. 统一绘制数据
插件输出与 Runtime 版本无关的 draw list:
```text
PreviewFrame
bounds
vertices[]
indices[]
commands[]
DrawCommand
textureHandle
firstIndex
indexCount
blendMode
premultipliedAlpha
```
顶点至少包含:
- position
- uv
- light color
- dark color
Clipping 优先在对应 Runtime/adapter 中完成,使主渲染器只处理最终三角形。
## 5. 纹理加载
主程序实现统一 Texture Provider:
- 根据 Atlas 页面路径读取本地文件。
- 拒绝 HTTP/HTTPS 和 data URL。
- 规范化相对路径并阻止目录逃逸。
- 支持 PNG、JPG/JPEG、WebP。
- 保留源 Alpha 模式元数据。
- 缓存按 canonical path、修改时间和文件大小失效。
图片解码插件必须随安装包部署,不能在运行时下载。
## 6. 渲染功能
首版必须支持:
- Region 和 Mesh
- Weighted Mesh、Linked Mesh
- Clipping
- Normal、Additive、Multiply、Screen
- 单颜色和 Two Color Tint
- PMA 和 Straight Alpha
- 多页 Atlas
- 高 DPI
- 骨骼、Mesh、边界、裁剪调试层
Shader 作为本地资源随程序部署,并在构建期或启动期从本地加载。
## 7. 播放状态
每个预览会话保存:
```text
selectedAnimation
selectedSkin
loop
playbackSpeed
time
paused
camera
background
debugFlags
```
切换资源组时保留各组会话,避免用户往返查看时丢失位置。
Seek 需要确定性:先恢复 setup pose,再从动画起点应用到目标时间,不能依赖累积浮点步进得到任意帧。
## 8. 错误隔离
首版采用动态插件;技术原型评估损坏 SKEL 是否可能使上游 Runtime 崩溃。如果无法通过输入边界检查和测试降低风险,发布版改为每个版本一个 helper process:
```text
UI ↔ local IPC ↔ preview-worker-4_2
```
是否进程隔离由原型的崩溃与模糊测试结果决定,不在未验证前锁死。
## 9. 性能目标
- 普通单骨骼资源首次预览在本地 SSD 上目标小于 1 秒。
- 预览默认 60 FPS;窗口不可见时暂停刷新。
- 大资源限制最大纹理尺寸、顶点数、附件数和单帧命令数。
- 纹理解码在线程池执行,OpenGL 创建在渲染线程执行。
- 切换动画不重复加载 Atlas 纹理。
## 10. 预览验收
- 五个版本官方示例的选定动画与官方参考视觉一致。
- 动画名、Skin 名和时长与 Runtime 数据一致。
- PMA/Straight Alpha 无明显黑边或白边。
- Mesh、Clipping、双颜色 Tint 和混合模式分别有测试样本。
- 缺失纹理不会导致崩溃,并能在画布上定位缺失项。
- 拔网后所有预览功能保持可用。
+67
View File
@@ -0,0 +1,67 @@
# Atlas 官方解包设计
## 1. 唯一实现
纹理恢复只调用用户本机、已授权且与数据版本匹配的 Spine Editor Texture Unpacker。项目不实现 Atlas 裁切、旋转、透明边恢复或图片重采样,也不存在自研兼容解包路径。
```text
本地匹配版本 Spine Texture Unpacker
↓
输出校验
↓
失败并向用户显示具体原因
```
官方解包失败、输出缺失或校验失败时,任务直接失败,不尝试以另一套图片算法生成看似成功的结果。
## 2. 官方调用
```text
Spine -u <本地精确缓存版本>
-i <Atlas 纹理页目录>
-o <临时 images 目录>
-c <Atlas 文件>
```
- 只允许精确的本地缓存版本,禁止 `latest`、通配版本和强制下载参数。
- 原始 Atlas 与纹理页保持只读。
- 输出先写入任务临时目录,验证成功后再发布。
- 记录退出码与进程输出;成功退出后仍检查是否实际生成图片。
- 最终结果不复制原始 `.atlas` 和打包纹理页。
## 3. PMA 处理
PMA 转换同样交给官方 Texture Unpacker:
- 开启“去除预乘 Alpha”时,原样传入源 Atlas,由 Spine 根据 `pma: true` 执行官方转换。
- 关闭时,仅建立临时 Atlas 元数据副本并把 `pma: true` 改为 `pma: false`,使官方解包器保留打包像素。
- 不再由 Qt 解码、逐像素处理或重新保存解包图片,避免 JPEG/WebP 二次有损编码以及错误的重复去预乘。
## 4. Atlas Scale 与工程导入
Texture Unpacker 输出的是 Atlas 实际打包尺寸。例如页面声明 `scale: 0.5` 时,解出的图片也是原图的一半像素尺寸,这不是压缩错误。
生成工程时必须把同一 Atlas 传给官方导入参数:
```text
Spine -u <本地精确缓存版本>
-i <骨骼 JSON 或 SKEL>
-s <Atlas 的 scale 数值>
-o <目标 .spine>
--import
```
`-s <数值>` 让 Editor 按 Atlas 的打包比例缩放骨骼、附件和动画数据。使用数值是为了兼容 3.8—4.3;旧版 Editor 不接受 Atlas 路径作为 `-s` 的值。遗漏此参数会形成“附件位置沿用原始坐标、解包图片却是半尺寸”的工程,表现为局部图片缩小和脱节。
多页 Atlas 必须使用一致的 `scale`;未声明时按 `1.0`。若各页比例不同,任务会说明原因并停止,避免生成内部尺寸不一致的工程。
## 5. 验证与失败规则
每次解包后至少检查:
- 进程正常结束。
- `images/` 中存在可识别的图片输出。
- 最终 `.spine` 文件存在且非空。
- 3.8、4.0、4.1、4.2、4.3 均使用对应版本 Editor 做导入回归。
任何一步失败都弹窗显示所属阶段和 Spine 返回的原因,不切换到非官方实现。
+195
View File
@@ -0,0 +1,195 @@
# 测试策略
## 1. 测试目标
测试需要证明四件事:
1. 每个目标版本使用正确 Runtime 和 Editor。
2. 预览结果在语义和视觉上正确。
3. 生成 `.spine` 后运行时主体数据没有意外丢失。
4. 断网、损坏输入和批量失败情况下行为安全。
## 2. 测试层级
### 2.1 单元测试
不启动 Spine Editor:
- 版本字符串解析
- Spine JSON schema 识别
- SKEL 头部有界探测
- Atlas parser
- 自动配对评分
- 路径规范化和逃逸防护
- 输出重名策略
- 恢复状态机
- 报告序列化
- Editor 命令参数生成
### 2.2 组件测试
- 每个 Runtime 插件独立加载相应版本资源。
- Texture Provider 解码 PNG、JPG、WebP。
- 统一 draw list 校验。
- 官方解包结果的尺寸、Alpha 与 golden image 对比。
- Editor Locator 使用模拟注册信息和目录结构。
### 2.3 Editor 集成测试
需要本地已激活的 Spine Editor:
- `--version` 探测
- JSON 导入
- SKEL 导入
- Atlas 官方解包
- 临时工程导出规范化 JSON
- 最终 `.spine` 可重新读取
- 取消、超时和错误码映射
### 2.4 端到端测试
从目录扫描开始,经过预览、确认、解包、导入、验证和发布。检查输入目录完全未改变。
## 3. 版本矩阵
每个版本至少覆盖:
| 版本 | JSON | SKEL | 预览 | 官方解包 | 工程生成 | 语义回读 |
|---|---:|---:|---:|---:|---:|---:|
| 3.8.20+ | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 |
| 4.0.x | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 |
| 4.1.x | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 |
| 4.2.x | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 |
| 4.3.x | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 |
## 4. 功能样本矩阵
- Region
- Unweighted/Weighted/Linked Mesh
- IK、Transform、Path constraints
- Clipping
- Deform
- Draw order
- Events
- 多动画、多 Skin
- Two Color Tint
- Normal/Additive/Multiply/Screen
- 单页、多页 Atlas
- PMA、Straight Alpha
- PNG、JPG/JPEG、WebP
- Atlas scale
- Polygon packing
不要求一个样本覆盖所有功能,采用可追踪的样本能力清单。
## 5. 官方测试资源
开发阶段从官方 `EsotericSoftware/spine-runtimes` 仓库各版本分支取得对应样例:
```text
3.8
4.0
4.1
4.2
4.3
```
每份资源由 manifest 记录:
```text
case id
source repository
branch
commit hash
relative paths
data version
features
expected animations/skins/counts
license reference
sha256
```
规则:
- 不用 4.3 资源测试旧 Runtime。
- 测试基于固定 commit,不追随浮动分支。
- 官方资源只放在开发测试缓存或 CI 临时空间。
- `tests/fixtures/external/` 不进入发行打包输入。
- 发行产物检查中把官方资源文件名和哈希作为禁止项。
- 未经许可确认,不把官方资源提交到对外发布制品。
## 6. 自有与生成测试数据
除官方样例外,创建可自由分发的最小数据用于:
- 安装后冒烟测试
- 损坏输入测试
- Unicode 路径测试
- 极限尺寸测试
- 输出命名冲突测试
生成数据的来源、生成脚本和许可必须清晰。若生成 `.spine`/导出数据需要 Spine Editor,构建产物仍按许可审查。
## 7. 视觉回归
在固定 viewport、背景、时间点、Skin 和动画下截图:
- 与批准的 golden image 比较。
- 对 JPG/WebP 使用合理像素误差。
- 对 Alpha 边缘增加专门区域检测。
- 单独测试 PMA 黑边、Straight Alpha 白边问题。
- GPU 差异造成的小量误差采用阈值,不使用完全逐字节相等。
## 8. 语义回归
转换前后生成规范摘要并比较:
```text
bones
slots
skins
attachments by type
constraints by type
animations and duration
events
deform timelines
draw-order timelines
```
Nonessential 数据单独报告,不与运行时语义失败混合。
## 9. 异常与安全测试
- 截断 SKEL
- 超长字符串和伪造数组长度
- 深层/异常 JSON
- Atlas 路径逃逸
- 重复 region 和页面
- 缺失或损坏图片
- 巨大图片和解压炸弹式输入
- Editor 路径失效
- Editor 未激活
- 子进程卡住、崩溃、返回非零
- 用户在各阶段取消
- 输出目录无权限或空间不足
## 10. 离线测试
在 macOS 和 Windows 上分别执行:
1. 准备并验证对应 Editor 版本。
2. 禁用网络接口或在隔离环境阻断网络。
3. 启动 DateToSpine。
4. 完成扫描、预览、官方解包、生成和回读。
5. 记录任何网络连接尝试。
6. 确认不存在 CDN、在线字体、在线 Shader、更新或遥测依赖。
## 11. 发行物检查
对最终 `.app/.dmg/.exe/installer` 解包检查:
- 不含官方测试素材。
- 不含 Spine Editor。
- 包含 Runtime License 和 Third-Party Notices。
- 包含所需 Qt 图片插件,WebP 在断网环境可用。
- 不含开发日志、绝对源码路径、测试缓存或调试密钥。
@@ -0,0 +1,134 @@
# 离线、打包与许可
## 1. 离线定义
DateToSpine 的“全功能离线”定义为:
- 应用本身不建立网络连接。
- 不使用 CDN、远程字体、远程 Shader、WebView 在线页面或在线 API。
- 不在运行时下载 Qt、Runtime、图片解码器或其他依赖。
- 不上传用户资源、路径、日志或统计数据。
- 不包含自动更新和联网遥测。
- 用户已经准备好的匹配版本 Spine Editor 可在断网环境中完成转换。
Spine Editor 是外部前置软件。DateToSpine 不控制 Editor 自身的授权策略,但必须在断网验收中验证既有激活和已缓存版本能够完成流程。
## 2. 构建期与运行期网络
- 五套正式 Spine Runtime 源码直接随主仓库提供,不使用 Submodule、FetchContent 或构建期下载。
- 内网环境克隆主仓库后即可取得完整 Runtime;若文件缺失,CMake 直接报错,不尝试联网补齐。
- Qt、CMake 和编译器属于预先安装的构建工具,不由本项目联网安装。
- 官方测试资源只存在于开发源码树,不进入最终安装包。
- 最终应用运行时不得使用网络。
- CI 下载的官方测试资源不进入打包阶段的输入集合。
## 3. macOS 打包
目标:
- Apple Silicon 原生包。
- Intel 或 Universal 2 是否首发,由原型性能和构建环境决定。
- `.app` 和 `.dmg`。
- Qt framework、platform plugin、image plugin、Runtime 插件和本地资源完整部署。
- 正式发行执行代码签名和 notarization;这是发布流程,不是最终应用运行时联网依赖。
应用不得修改用户的 Spine.app 或其缓存。
## 4. Windows 打包
目标:
- Windows 10/11 x64。
- MSVC 2022 v143。
- 使用 `/MD` 与 Qt 官方构建一致。
- 部署 MSVC Runtime、Qt platform plugin、imageformats 和 Runtime 插件。
- 安装包格式在原型后从 MSIX、WiX/MSI 或 Inno Setup 中确定。
- 正式发行建议代码签名。
最终用户不需要安装 CMake、Visual Studio 或 Qt SDK。
## 5. Runtime 许可
项目集成官方 Spine Runtimes 时必须遵守:
- [Spine Runtimes License Agreement](https://esotericsoftware.com/spine-runtimes-license)
- [Spine Editor License Agreement](https://esotericsoftware.com/spine-editor-license)
发行前需要法务或产品负责人确认最终分发模式。技术实现至少保证:
- `licenses/` 保存完整、未修改的 Runtime 许可文本。
- 安装包包含 Runtime 许可和版权声明。
- About/关于 页面提供第三方许可入口。
- 不隐藏用户需要合法 Spine 许可的前提。
- 不分发 Spine Editor、激活信息或 Editor 更新文件。
- Runtime 插件不作为通用 SDK 对外提供开发接口。
## 6. 官方测试资源
已确认规则:
- 官方 GitHub 仓库资源只用于开发、兼容性测试和 CI。
- 官方测试资源不进入最终安装包。
- 打包清单不包含 `tests/fixtures/external/`。
- 发行检查使用路径、文件名和 SHA-256 禁止列表防止误打包。
- 测试报告可记录资源 case id 和哈希,但不嵌入资源文件。
## 7. 第三方依赖清单
每个依赖记录:
```text
name
version/commit
source URL
license
modified or unmodified
linked statically or dynamically
included platforms
notices path
```
至少包括:
- Qt 6
- Spine Runtimes 3.8/4.0/4.1/4.2/4.3
- 测试框架
- 可能引入的日志或打包依赖
新增依赖必须经过:离线可部署性、许可证、维护状态、体积和安全评审。
## 8. 网络禁止措施
- 不链接应用层 HTTP 客户端,除非未来需求重新评审。
- 资源加载器只接受本地文件协议。
- 代码扫描阻止 CDN URL 和动态依赖下载逻辑。
- 集成测试在网络阻断环境运行。
- 打包后执行连接监控测试。
文档中的官方链接仅供人阅读,不会被应用自动访问。
## 9. 发行目录白名单
打包采取白名单而不是“复制整个构建目录”:
```text
application executable
Qt runtime libraries/plugins
dts runtime plugins
icons/translations/shaders
licenses/notices
user documentation
```
明确排除:
```text
tests/
official sample assets
source repository metadata
developer caches
temporary recovery jobs
Spine Editor binaries
activation information
debug-only symbols(独立保存,不进入普通安装包)
```
+131
View File
@@ -0,0 +1,131 @@
# 开发计划
## 1. 开发原则
- 先验证风险,再建设完整 UI。
- 每个阶段有可运行结果和退出标准。
- 未通过技术关卡时不扩大代码量掩盖问题。
- 文档、测试和代码同步更新。
- 当前文档批准后才进入功能开发。
## 2. 阶段 0:技术原型
### 目标
验证决定产品能否成立的核心问题。
### 工作项
1. 在 macOS 上发现并验证已安装 Spine Editor。
2. 验证自定义 `.app`/可执行文件路径。
3. 调用 3.8、4.0、4.1、4.2、4.3 本地版本。
4. 为五个版本各构建最小 `spine-cpp` adapter。
5. 用 JSON、SKEL、Atlas 和纹理加载一帧预览数据。
6. 验证 QOpenGLWidget 绘制 Region、Mesh、Clipping 和混合模式。
7. 验证官方 Texture Unpacker。
8. 验证 SKEL → 临时 `.spine` → JSON → 设置 images → 最终 `.spine`。
9. 验证断网运行。
10. 在 Windows/MSVC 至少完成构建和 Editor CLI 冒烟测试。
### 技术关卡
- 五套 Runtime 可以隔离构建并通过统一 ABI 加载。
- 五个版本至少各有一个 JSON/SKEL 可预览。
- 本地 Editor 路径和版本能可靠识别。
- JSON/SKEL 可以生成可回读 `.spine`。
- 图片路径能自动关联,或明确证明需要采用哪种降级方案。
- 官方解包和 PMA 行为被记录。
- 断网时已准备版本可执行完整流程。
任何关卡失败都先更新架构决策,不进入大规模 UI 开发。
## 3. 阶段 1:工程骨架与领域模型
- CMake Presets 和平台 toolchain。
- 目录、target 和第三方依赖边界。
- `AssetGroup`、`SpineVersion`、`RecoveryJob`。
- 文件系统、进程和报告接口。
- 基础错误码和状态机。
- 单元测试框架和 CI 基线。
- 自研代码行数检查。
退出标准:macOS/Windows Debug 与 Release 构建通过,核心模型测试通过。
## 4. 阶段 2:扫描、配对与预检
- 递归扫描。
- JSON/SKEL 版本识别。
- Atlas parser。
- 页面解析和图片校验。
- 资源组配对评分。
- 手动修正模型。
- Unicode、长路径和安全边界。
退出标准:官方样例和异常样例能形成确定、可解释的资源组。
## 5. 阶段 3:离线预览器
- 五个 Runtime 插件完整实现。
- Texture Provider。
- OpenGL 批次渲染。
- 动画、Skin、Seek、速度控制。
- 调试显示。
- 视觉回归测试。
退出标准:五个版本功能矩阵通过,拔网预览正常。
## 6. 阶段 4:Editor 管理与转换
- macOS/Windows 自动发现。
- 自定义路径 UI。
- 版本能力映射。
- Spine CLI 封装。
- JSON/SKEL 导入。
- 图片路径规范化。
- 语义回读验证。
- 原子发布和恢复报告。
退出标准:五个版本端到端转换通过,输入未改变。
## 7. 阶段 5:Atlas 解包
- 官方解包器唯一流程。
- 官方结果验证。
- PMA、Scale、rotate、offset、多页和 polygon packing。
- PNG/JPG/WebP 测试。
退出标准:官方解包与失败原因可观察、可报告,且各版本具有视觉回归。
## 8. 阶段 6:批处理和用户体验
- 任务队列、取消和失败隔离。
- 批量重名策略。
- 进度、日志和错误建议。
- 设置迁移和会话恢复。
- 中英文界面。
- 无障碍和高 DPI 检查。
## 9. 阶段 7:发布准备
- macOS/Windows 安装包。
- Qt 和图片插件部署。
- Runtime 许可与 Third-Party Notices。
- 官方测试资源排除检查。
- 离线端到端测试。
- 签名、公证和干净机器安装测试。
- 性能、内存和损坏输入测试。
## 10. 建议评审点
每阶段完成后提交:
- 功能演示
- 自动测试结果
- 新增风险
- 文件长度报告
- 依赖与许可变更
- 下一阶段范围
## 11. 编码顺序建议
开始开发后的第一个提交不应是完整项目 UI,而应是阶段 0 的最小原型和实验记录。技术原型通过后,再固化公共接口和正式目录。
@@ -0,0 +1,102 @@
# 风险与架构决策
## 1. 风险台账
| ID | 风险 | 影响 | 应对 |
|---|---|---|---|
| R-01 | 不同 Spine 版本二进制格式不兼容 | 错误预览或导入失败 | 五套匹配 Runtime;Editor 主/次版本强校验 |
| R-02 | 3.8 早期格式变化 | 部分 3.8 文件无法读取 | 正式范围从 3.8.20 开始;旧文件尽力识别 |
| R-03 | SKEL 不含可直接修改的图片路径 | 生成工程显示 Missing | Editor 双重导入/导出原型;输出布局降级方案 |
| R-04 | 五套 Runtime 符号/API 冲突 | 无法链接或运行不稳定 | 独立动态模块、隐藏符号、稳定 C ABI |
| R-05 | 损坏输入使 Runtime 崩溃 | 主程序退出 | 有界预检、模糊测试;必要时改 helper process |
| R-06 | Spine Launcher 缺少目标缓存版本 | 离线转换失败 | 版本能力预检;不自动下载;明确修复提示 |
| R-07 | Editor 自身的联网或授权行为 | 离线验收失败 | 真正断网测试;明确前置条件;不修改或绕过 Editor |
| R-08 | PMA/WebP 边缘错误 | 预览或解包出现色边 | 格式矩阵与 Alpha 视觉回归;Alpha 处理交给官方解包器 |
| R-09 | Polygon-packed Mesh 难以还原原图 | 图片包含邻近像素或裁切错误 | 仅使用官方解包器;失败时报告限制并终止 |
| R-10 | 官方测试素材误入安装包 | 许可与体积风险 | 打包白名单、哈希禁止清单、发行物解包检查 |
| R-11 | Qt/Runtime 许可遗漏 | 无法合规发行 | 依赖清单、licenses、About 页面、发行关卡 |
| R-12 | 自定义路径和 Unicode 处理错误 | Editor 无法启动 | 参数数组、canonical path、双平台路径测试 |
| R-13 | 巨大资源耗尽内存 | 卡死或崩溃 | 输入限制、预算检查、按需解码、可取消任务 |
| R-14 | 跨补丁 Editor 行为变化 | 同一主/次版本结果不同 | 记录实际补丁;固定测试矩阵;报告转换环境 |
## 2. 已接受架构决策
### ADR-001:生成 `.spine` 使用官方 Editor
- 状态:接受
- 决策:调用本地已授权 Spine Editor,不自行写 `.spine`。
- 原因:`.spine` 是编辑工程格式;官方 CLI 已提供数据导入能力。
### ADR-002:版本范围为 3.8.20+ 至 4.3
- 状态:接受
- 决策:3.8.20+、4.0、4.1、4.2、4.3;不支持 3.8 以下。
- 原因:覆盖目标游戏资源,同时控制旧格式维护成本。
### ADR-003:预览进入首版
- 状态:接受
- 决策:用户确认转换前必须可离线预览。
- 原因:避免批量生成后才发现配对、Scale、Alpha 或版本错误。
### ADR-004:每个主/次版本独立 Runtime
- 状态:接受
- 决策:五个 Runtime 插件,通过统一 C ABI 接入。
- 原因:官方要求 Runtime 与数据主/次版本同步,API 也会变化。
### ADR-005:仅使用官方 Atlas 解包
- 状态:接受
- 决策:官方 Texture Unpacker 是唯一纹理解包实现;失败时任务失败。
- 原因:官方对自身 Atlas、PMA、旋转和多边形打包语义最权威,且软件以前置正版 Spine 为使用条件。
### ADR-006:运行期全离线
- 状态:接受
- 决策:无 CDN、下载、遥测、资源上传和在线 API。
- 原因:资源敏感性、稳定性和用户要求。
### ADR-007:官方测试资源不发行
- 状态:接受
- 决策:官方资源只用于开发/CI,最终安装包不包含。
- 原因:用户明确要求,并减少许可与安装体积风险。
### ADR-008:Qt 6 + CMake + MSVC/Apple Clang
- 状态:接受
- 决策:主程序 C++20,CMake 3.25+,Windows MSVC v143。
- 原因:跨平台 UI、进程管理、图片插件和成熟构建生态。
## 3. 原型后待决策事项
### ADR-P01:Runtime 插件还是 helper process
默认先验证动态插件。若损坏文件可导致不可接受的进程级崩溃,则采用 helper process 隔离。
### ADR-P02:macOS 架构
首发 Apple Silicon 或 Universal 2,根据目标用户和五套 Runtime 构建成本决定。
### ADR-P03:测试框架
Qt Test 与 Catch2 二选一,以 Runtime 插件和参数化兼容矩阵的便利性为主要标准。
### ADR-P04:SKEL 图片路径自动化方案
由 Editor 双重导入/导出原型结果决定正式流程及是否存在可接受降级。
### ADR-P05:Windows 安装器
在 MSIX、WiX/MSI、Inno Setup 中选择,需满足离线安装、签名和干净卸载。
## 4. 决策变更规则
变更已接受决策时必须记录:
- 变更原因
- 新证据或测试结果
- 对范围、兼容性和许可的影响
- 数据迁移或设置迁移方案
- 负责人和日期
@@ -0,0 +1,93 @@
# 阶段 0 技术原型记录
## 记录日期
2026-09-07
## 当前结论
首批工程骨架已经建立并在 macOS Apple Silicon 上通过构建测试。此阶段证明多版本 Runtime 隔离、基础输入识别、本地 Editor 发现和跨平台进程抽象可以按既定架构继续推进;尚未证明完整动画预览或 `.spine` 恢复流程。
## 本机环境
- Apple Clang 17
- CMake 4.4.3(项目最低要求仍为 3.25)
- Ninja 1.13.2
- Qt Base 6.11.2
- Qt Image Formats 6.11.2
- Spine Launcher 4.3.06 Apple Silicon
- Launcher 当前观察到启动 Spine Editor 3.8.99 Professional
只安装 Qt Base 与 Qt Image Formats,没有安装 Qt WebEngine、Location、NetworkAuth 等与产品无关的 Qt 元组件。
## 固定 Runtime 提交
| Runtime | 官方分支 | 提交 |
|---|---|---|
| 3.8 | `3.8` | `8b4844bd4b193ba9e54487ed397a777993cbad56` |
| 4.0 | `4.0` | `425ce416bb218b28caeec47b317aa57cd7140375` |
| 4.1 | `4.1` | `77a5db0ec6d16331f5efbaa7662bba9355bd3424` |
| 4.2 | `4.2` | `e7dc1435fa4a0083ab431f1b28e083c14a1f5c68` |
| 4.3 | `4.3` | `4309c05c287d3f15da778e68f5d2a483fe10a6a3` |
当前只展开各分支的 `spine-cpp` 源码。4.3 官方 Spineboy 资源仅用于开发测试,不进入安装目标和发行包。
## 已实现
- CMake/C++20 工程和 macOS、Windows Preset。
- 标准 C++ 核心库,不依赖 Qt。
- Spine 版本值对象和正式支持范围判断。
- JSON 最小 Spine schema 与版本探测。
- SKEL 二进制头部 hash/version 有界读取。
- Atlas 多页、page、region、PMA、scale、rotate、bounds/legacy 字段基础解析。
- macOS 常见 Spine 路径发现和 `.app` 规范化。
- Windows 常见路径骨架和 `.exe` → `.com` 规范化。
- 不经过 shell 的 POSIX/Windows 子进程执行器。
- 超时后终止本软件启动的整个子进程树。
- Launcher 版本与实际 Editor 版本分离解析。
- 五个 Runtime 独立静态目标和动态 adapter。
- 稳定 C ABI 版本与 metadata 加载验证。
- 极简 Qt 桌面外壳:资源拖放/选择、资源切换、动画切换和一键转换。
- Editor 自动发现;仅在自动发现失败时请求自定义路径。
- 4.3 Runtime 已实际加载官方 JSON、SKEL 和 Atlas,并输出动画/Skin/骨骼元数据。
- 4.3 Runtime 已输出裁剪后的纹理三角形,Qt 画布可循环播放并切换真实动画。
- 4.3 beta 后缀版本和新版 SKEL 文件头识别。
- CLI:Editor 发现、路径探测、数据版本检查。
## 构建与测试结果
- `cmake --preset macos-debug`:通过。
- `cmake --build --preset macos-debug`:通过。
- 五个官方 Runtime:通过 Apple Clang 编译。
- 五个 Runtime adapter:通过动态加载和 ABI/版本检查。
- 核心单元测试:全部通过。
- 官方 Spineboy JSON/SKEL 真实加载测试:通过,识别到 11 个动画。
- Qt 应用 offscreen 启动冒烟测试:通过。
Spine 4.3 上游源码在 Apple Clang 下产生一条未覆盖枚举值的 warning。第三方源码没有被修改,该警告暂不影响构建,后续记录在上游警告基线中。
## 实际 Editor 探测发现
直接执行本机 `Spine --version` 时先报告 Launcher 4.3.06,随后启动 3.8.99 Professional。旧版 launcher 在当前系统产生字体兼容警告,并且探测可能长时间不退出。
因此代码已经采用:
- 分离解析 Launcher 和 Editor 版本。
- 探测超时。
- 进程树终止。
- “已观察到 Spine 但探测超时”独立状态。
仅看到 Launcher 版本不能代表目标 Editor 版本可用。
## 尚未完成的阶段 0 关卡
1. 对 3.8—4.2 执行真实资源预览。
2. 目标 Editor 版本选择与离线缓存能力验证。
3. 官方 Texture Unpacker 调用与结果验证。
4. JSON/SKEL 到 `.spine` 的端到端恢复。
5. SKEL 图片路径双重导入/导出方案验证。
6. Windows MSVC 构建与真实 Editor 测试。
## 下一步
先扩展 Runtime C ABI,以 4.3 adapter 实现真实资源加载、动画/Skin 元数据与 draw list;通过后把版本差异分别适配到 4.2、4.1、4.0 和 3.8。随后接 Qt OpenGL 预览画布,再进入 Editor 导入和官方解包流水线。
@@ -0,0 +1,26 @@
# 极简界面阶段记录
## 交互结论
主界面已经收敛为“拖入、预览、切换动画、一键转换”。版本号、文件格式、校验状态和 Editor 管理等技术信息不再常驻显示。
## 当前可查看产出
- 空状态支持拖入 `.json`、`.skel`、`.atlas`、纹理或整个目录。
- 多套资源通过顶部单一选择框切换。
- 4.3 官方 Spineboy JSON/SKEL 可读取,动画选择框显示 11 个真实动画。
- 当前画布已使用官方 4.3 Runtime 绘制真实骨骼姿态,并默认循环播放 `idle`。
- 动画下拉框会实际切换 Runtime 动画,不再只是显示名称。
- “一键转换为 .spine”按钮已接入本地 Spine Editor 自动发现和异步导入入口。
- 自动发现 Editor 失败时才弹出路径选择,不再提供独立管理窗口。
- 软件没有网络请求。
界面截图:`阶段产出/极简界面-资源已载入.png`。
## 尚未宣称完成
- 当前真实动画渲染已覆盖 4.3,3.8—4.2 尚未接入统一绘制接口。
- 一键转换入口已形成,但官方解包、图片路径恢复和生成物回读验证尚未串成最终流水线。
- 3.8—4.2 Runtime 仍需补齐真实数据加载和绘制接口。
这些未完成项不会通过增加设置页面暴露给用户,仍保持后台自动化和极简主流程。
@@ -0,0 +1,40 @@
# 4.3 动画预览阶段记录
## 阶段结果
4.3 官方 Runtime 已从“只读资源信息”升级为真实动画绘制。主界面现在会直接显示骨骼角色,而不是整张 Atlas 纹理。
## 已实现
- Runtime ABI 增加动画选择、逐帧更新和绘制命令接口。
- 4.3 adapter 创建 `Skeleton`、`AnimationState` 和 `SkeletonRenderer`。
- 输出世界坐标、UV、颜色、索引、纹理路径和混合模式。
- Runtime 内部完成 Mesh 和 Clipping 处理。
- Qt 独立预览画布按纹理三角形绘制并自动适应窗口。
- 支持 Normal、Additive、Multiply、Screen 四种混合模式映射。
- 默认循环播放动画列表第一项;列表为空时保持静态姿态,不启动动画播放。
- 用户切换下拉框时立即切换动画并循环播放。
- 预览画布尺寸独立于每帧骨骼边界;首次加载后锁定视口,动画过程不再忽大忽小。
- 默认以画布可容纳的最大比例显示,并保留少量安全边距。
- 滚动鼠标中键时,以鼠标所在位置为中心缩放画布。
- 单击鼠标中键时重置缩放和画布位置。
- 按住鼠标右键拖动时平移画布;右键短按时回到中心。
- 软件绘制路径消除了逐三角形抗锯齿造成的 Mesh 接缝。
## 测试证据
官方 Spineboy 4.3 JSON 测试已经验证:
- 动画可被选择。
- 每帧能产生绘制命令、顶点和三角形索引。
- 绘制命令引用的纹理文件存在。
- 时间推进后世界坐标确实发生变化。
- JSON 和 SKEL 仍能重复加载。
阶段截图:`阶段产出/骨骼动画预览-4.3.png`。
固定画布截图:`阶段产出/固定画布最大显示-4.3.png`。
## 后续范围
下一步复用相同 ABI 分别适配 4.2、4.1、4.0 和 3.8。各版本仍使用独立官方 Runtime,不把跨版本差异塞入界面代码。
@@ -0,0 +1,81 @@
# 离线转换与多版本回归记录
## 1. 本阶段结果
- 资源载入完成后自动检测匹配版本线,并在顶部显示是否可转换。
- 4.3 资源在本机使用已缓存的 Spine `4.3.23`,不再把资源导出补丁号直接传给 Launcher。
- 只传精确缓存版本;禁止 `latest`、通配版本和 `--force`。
- 缓存缺失时不启动 Spine,转换任务内部也会二次拦截。
- 成功和失败都显示弹窗;失败弹窗包含具体阶段与进程输出摘要。
- 最终结果只包含 `.spine` 和 `images/`,不包含原始 Atlas 或打包纹理页。
- 底部增加“去除预乘 Alpha”开关,默认关闭。
界面产出:[离线转换与去预乘开关.png](阶段产出/离线转换与去预乘开关.png)
## 2. 输出结构
```text
选择的输出位置/
spineboy-pro/
spineboy-pro.spine
images/
head.png
torso.png
...
```
同名目录存在时使用 `-2`、`-3` 后缀。所有内容先写入同一输出磁盘的临时目录,校验成功后再整体发布;失败不会留下伪成功目录。
## 3. Alpha 处理
官方 CLI 没有独立的去预乘开关,而是读取 Atlas 的 `pma` 值。为让 3.8—4.3 具有统一、可控的行为:
1. 原始 Atlas 与纹理页始终只读。
2. 开启开关时原样传入 Atlas,由官方 Texture Unpacker 根据 `pma` 完成转换。
3. 关闭开关时建立临时 Atlas 元数据副本,将 `pma:true` 改为 `pma:false`,再由官方解包器保留打包像素。
4. 不使用 Qt 对输出图片做逐像素变换或重新编码。
5. 临时 Atlas 随任务工作区清理,不进入结果目录。
## 4. 工程图片尺寸问题
已确认根因不是官方解包器压缩图片。测试 Atlas 声明 `scale: 0.5`,官方解包得到的 `head.png` 为 136×149、`front-shin.png` 为 41×92,符合实际打包尺寸。旧流程导入骨骼数据时没有传 Atlas Scale,导致附件坐标保持原始尺寸,而图片只有打包尺寸,于是局部图片缩小、脱节。
修复后的工程导入读取 Atlas 的 `scale`,再增加 `-s <数值>`,由匹配版本 Spine Editor 统一缩放骨骼、附件与动画数据。使用数值可以兼容不接受 Atlas 路径参数的 4.0 等旧版 Editor。
## 5. 多版本预览回归
每条版本线使用对应官方 `spine-cpp` 和该分支的 Spineboy 开发样本。样本仅用于开发测试,不进入安装包。
| 版本 | JSON | SKEL | 动画选择 | 绘制帧 |
|---|---|---|---|---|
| 3.8 | 通过 | 通过 | 通过 | 通过 |
| 4.0 | 通过 | 通过 | 通过 | 通过 |
| 4.1 | 通过 | 通过 | 通过 | 通过 |
| 4.2 | 通过 | 通过 | 通过 | 通过 |
| 4.3 | 通过 | 通过 | 通过 | 通过 |
3.8—4.1 共享一个旧版绘制适配层,4.2 和 4.3 使用各自 Runtime 自带的 `SkeletonRenderer`,避免将版本差异堆叠到主界面代码。
### 5.1 旧版 UV 方向修正
3.8—4.2 的 Runtime 输出沿用 Y 轴向上的世界坐标,而当前 Qt 画布以左上角为原点。适配层在输出预览顶点时对这些版本执行 `y = -y`;旧版 UV 已经与 Runtime 的三角形顶点顺序配套,不能再次翻转。4.3 已符合当前预览器方向,不执行该转换。
修复后截图:[旧版预览方向修复-4.2.png](阶段产出/旧版预览方向修复-4.2.png)
## 6. 本机实际转换回归
所有命令均选择已经存在的精确缓存版本:
| 资源线 | 本地 Editor | JSON 导入 | SKEL 导入 | 解包图片数 |
|---|---:|---:|---:|---:|
| 3.8 | 3.8.99 | 前一轮通过;本轮缓存不可用 | 前一轮通过;本轮缓存不可用 | 40(前一轮) |
| 4.0 | 4.0.64 | 55,295 字节 | 58,616 字节 | 40 |
| 4.1 | 4.1.24 | 55,483 字节 | 58,908 字节 | 40 |
| 4.2 | 4.2.43 | 50,811 字节 | 54,940 字节 | 40 |
| 4.3 | 4.3.23 | 49,603 字节 | 49,905 字节 | 40 |
4.2 A/B 回导验证:未传比例时 `hip.y=247.27`、前胫骨长度为 `128.77`;传 `-s 0.5` 后分别为 `123.64` 和 `64.39`,Mesh 顶点也同步减半,而图片维持官方解包尺寸。这说明工程数据与图片比例已恢复一致。
本轮 3.8 缓存文件在回归前已不可用,ARM Launcher 无法加载旧 x86 版本,因此遵守离线约束,没有尝试联网补齐。3.8 官方样本不声明 Atlas `scale`,修复路径会使用 `1.0`,与原导入行为一致;待本机重新具备可运行的 3.8 Editor 后仍需补跑本轮实机导入。
自动测试与完整构建通过。官方测试资源仍位于开发期 `third_party` 目录,安装规则只复制 Runtime 许可文件。
+62
View File
@@ -0,0 +1,62 @@
# DateToSpine 开发文档
## 文档状态
- 状态:技术原型开发中,极简主界面已形成
- 文档基线:2026-09-07
- 当前目录包含设计文档、C++/Qt 工程、Runtime 适配器和自动测试
## 产品概述
DateToSpine 是一个面向 macOS 和 Windows 的离线桌面工具,用于扫描、预览并批量恢复 Spine 游戏运行时资源:
```text
.skel/.json + .atlas/.atlas.txt + texture pages
↓
可编辑的 .spine 工程
```
软件内置匹配版本的官方 `spine-cpp` Runtime 以实现转换前预览。生成 `.spine` 时,用户电脑必须已经安装、激活并准备好与资源版本匹配的 Spine Editor。
## 已确认的产品决策
1. 正式支持 Spine `3.8.20+`、`4.0.x`、`4.1.x`、`4.2.x`、`4.3.x`。
2. 不支持 Spine 3.8 以下版本。
3. 转换前必须支持离线预览,用户确认后才生成 `.spine`。
4. 五个 Spine 主/次版本使用各自对应的官方 Runtime。
5. 生成 `.spine` 必须调用本地已授权的 Spine Editor,不自行编写或逆向 `.spine` 文件。
6. Atlas 纹理只使用 Spine 官方 Texture Unpacker 解包,不提供自研兜底。
7. 软件运行期不使用 CDN、在线 API、在线依赖下载、遥测或资源上传。
8. 官方测试资源仅用于开发和 CI 测试,不进入最终安装包。
9. 五套官方 Runtime 源码由主仓库直接携带,不使用 Submodule 或构建期下载。
9. 最终安装包只携带 Runtime 许可、第三方许可和必要声明,不携带 Spine Editor 或官方示例资源。
10. 自研源文件按职责拆分,禁止出现数千行的业务代码文件。
11. 主界面只保留拖入资源、预览/动画切换和一键转换;仅在 Editor 自动识别失败时请求路径。
## 文档索引
- [01-产品需求.md](01-产品需求.md):产品范围、用户流程和验收标准
- [02-系统架构.md](02-系统架构.md):技术架构、模块边界和代码约束
- [03-版本识别与编辑器检测.md](03-版本识别与编辑器检测.md):版本识别、本地 Editor 检测和自定义路径
- [04-恢复流水线.md](04-恢复流水线.md):恢复流水线、输出规则和错误处理
- [05-离线预览器.md](05-离线预览器.md):多版本 Runtime 与离线预览器
- [06-图集解包设计.md](06-图集解包设计.md):官方唯一解包、PMA 与 Atlas Scale 设计
- [07-测试策略.md](07-测试策略.md):测试矩阵、官方测试资源管理和验收
- [08-离线打包与许可.md](08-离线打包与许可.md):离线、发行与许可要求
- [09-开发计划.md](09-开发计划.md):阶段计划、技术关卡与完成定义
- [10-风险与架构决策.md](10-风险与架构决策.md):风险台账与架构决策记录
- [11-阶段0技术原型记录.md](11-阶段0技术原型记录.md):首批代码、构建证据和后续关卡
- [12-极简界面阶段记录.md](12-极简界面阶段记录.md):精简后的界面范围、当前产出与未完成项
- [13-四点三动画预览记录.md](13-四点三动画预览记录.md):4.3 真实骨骼绘制、动画切换和测试证据
- [14-离线转换与多版本回归记录.md](14-离线转换与多版本回归记录.md):本地版本门禁、完整输出、PMA 开关和 3.8—4.3 回归证据
## 官方参考资料
- [Spine Command Line Interface](https://en.esotericsoftware.com/spine-command-line-interface)
- [Importing skeleton data](https://esotericsoftware.com/blog/Importing-skeleton-data)
- [Spine versioning](https://esotericsoftware.com/spine-versioning)
- [spine-cpp Runtime Documentation](https://esotericsoftware.com/spine-cpp)
- [Spine JSON format](https://esotericsoftware.com/spine-json-format)
- [Spine binary format](https://esotericsoftware.com/spine-binary-format)
- [Spine Runtimes repository](https://github.com/EsotericSoftware/spine-runtimes)
- [Spine Runtimes License](https://esotericsoftware.com/spine-runtimes-license)
Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 952 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,26 @@
Spine Runtimes License Agreement
Last updated April 5, 2025. Replaces all prior versions.
Copyright (c) 2013-2025, Esoteric Software LLC
Integration of the Spine Runtimes into software or otherwise creating
derivative works of the Spine Runtimes is permitted under the terms and
conditions of Section 2 of the Spine Editor License Agreement:
https://esotericsoftware.com/spine-editor-license
Otherwise, it is permitted to integrate the Spine Runtimes into software
or otherwise create derivative works of the Spine Runtimes (collectively,
"Products"), provided that each user of the Products must obtain their own
Spine Editor license and redistribution of the Products in any form must
include this license and copyright notice.
THE SPINE RUNTIMES ARE PROVIDED BY ESOTERIC SOFTWARE LLC "AS IS" AND ANY
EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL ESOTERIC SOFTWARE LLC BE LIABLE FOR ANY
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES,
BUSINESS INTERRUPTION, OR LOSS OF USE, DATA, OR PROFITS) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
THE SPINE RUNTIMES, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Binary file not shown.
+90
View File
@@ -0,0 +1,90 @@
#pragma once
#include <stdint.h>
#if defined(_WIN32)
#define DTS_RUNTIME_EXPORT __declspec(dllexport)
#else
#define DTS_RUNTIME_EXPORT __attribute__((visibility("default")))
#endif
#ifdef __cplusplus
extern "C" {
#endif
enum { DTS_RUNTIME_API_VERSION = 3 };
enum DtsRuntimeCapability {
DTS_RUNTIME_CAP_SKELETON_METADATA = 1u << 0,
DTS_RUNTIME_CAP_DRAW_COMMANDS = 1u << 1
};
typedef struct DtsRuntimeInfo {
uint32_t structSize;
uint32_t apiVersion;
const char* spineVersionLine;
const char* adapterBuild;
uint32_t capabilities;
} DtsRuntimeInfo;
typedef void* DtsRuntimeHandle;
typedef struct DtsLoadRequest {
uint32_t structSize;
const char* skeletonPathUtf8;
const char* atlasPathUtf8;
float scale;
} DtsLoadRequest;
typedef struct DtsSkeletonInfo {
uint32_t structSize;
uint32_t boneCount;
uint32_t slotCount;
uint32_t skinCount;
uint32_t animationCount;
uint32_t eventCount;
uint32_t constraintCount;
} DtsSkeletonInfo;
typedef struct DtsRenderVertex {
float x;
float y;
float u;
float v;
uint32_t color;
} DtsRenderVertex;
typedef struct DtsDrawCommand {
uint32_t structSize;
const DtsRenderVertex* vertices;
uint32_t vertexCount;
const uint16_t* indices;
uint32_t indexCount;
const char* texturePathUtf8;
uint32_t blendMode;
} DtsDrawCommand;
DTS_RUNTIME_EXPORT uint32_t dtsRuntimeGetApiVersion(void);
DTS_RUNTIME_EXPORT int32_t dtsRuntimeGetInfo(DtsRuntimeInfo* info);
DTS_RUNTIME_EXPORT DtsRuntimeHandle dtsRuntimeCreate(void);
DTS_RUNTIME_EXPORT void dtsRuntimeDestroy(DtsRuntimeHandle handle);
DTS_RUNTIME_EXPORT int32_t dtsRuntimeLoad(DtsRuntimeHandle handle, const DtsLoadRequest* request);
DTS_RUNTIME_EXPORT int32_t dtsRuntimeGetSkeletonInfo(
DtsRuntimeHandle handle, DtsSkeletonInfo* info);
DTS_RUNTIME_EXPORT const char* dtsRuntimeGetAnimationName(
DtsRuntimeHandle handle, uint32_t index);
DTS_RUNTIME_EXPORT float dtsRuntimeGetAnimationDuration(
DtsRuntimeHandle handle, uint32_t index);
DTS_RUNTIME_EXPORT const char* dtsRuntimeGetSkinName(
DtsRuntimeHandle handle, uint32_t index);
DTS_RUNTIME_EXPORT const char* dtsRuntimeGetLastError(DtsRuntimeHandle handle);
DTS_RUNTIME_EXPORT int32_t dtsRuntimeSetAnimation(
DtsRuntimeHandle handle, const char* animationNameUtf8, int32_t loop);
DTS_RUNTIME_EXPORT int32_t dtsRuntimeUpdate(DtsRuntimeHandle handle, float deltaSeconds);
DTS_RUNTIME_EXPORT uint32_t dtsRuntimeGetDrawCommandCount(DtsRuntimeHandle handle);
DTS_RUNTIME_EXPORT int32_t dtsRuntimeGetDrawCommand(
DtsRuntimeHandle handle, uint32_t index, DtsDrawCommand* command);
#ifdef __cplusplus
}
#endif
@@ -0,0 +1,305 @@
#include "RuntimeApi.h"
#include <spine/Animation.h>
#include <spine/AnimationState.h>
#include <spine/AnimationStateData.h>
#include <spine/Atlas.h>
#include <spine/ClippingAttachment.h>
#include <spine/Extension.h>
#include <spine/MeshAttachment.h>
#include <spine/RegionAttachment.h>
#include <spine/Skeleton.h>
#include <spine/SkeletonBinary.h>
#include <spine/SkeletonClipping.h>
#include <spine/SkeletonData.h>
#include <spine/SkeletonJson.h>
#include <spine/Skin.h>
#include <spine/Slot.h>
#include <spine/SlotData.h>
#include <spine/TextureLoader.h>
#include <algorithm>
#include <cctype>
#include <cstddef>
#include <memory>
#include <string>
#include <vector>
#ifndef DTS_SPINE_LINE
#error DTS_SPINE_LINE must be defined before including LegacyRuntimeAdapter.inl
#endif
namespace {
struct TextureRecord { std::string path; };
class MetadataTextureLoader final : public spine::TextureLoader {
public:
void load(spine::AtlasPage& page, const spine::String& path) override {
#if defined(DTS_SPINE_41)
page.texture = new TextureRecord{path.buffer()};
#else
page.setRendererObject(new TextureRecord{path.buffer()});
#endif
}
void unload(void* texture) override { delete static_cast<TextureRecord*>(texture); }
};
struct RenderBatch {
std::vector<DtsRenderVertex> vertices;
std::vector<std::uint16_t> indices;
std::string texturePath;
std::uint32_t blendMode = 0;
};
struct RuntimeContext {
MetadataTextureLoader textureLoader;
std::unique_ptr<spine::Atlas> atlas;
std::unique_ptr<spine::SkeletonData> skeletonData;
std::unique_ptr<spine::Skeleton> skeleton;
std::unique_ptr<spine::AnimationStateData> animationStateData;
std::unique_ptr<spine::AnimationState> animationState;
spine::SkeletonClipping clipper;
std::vector<std::string> animationNames;
std::vector<float> animationDurations;
std::vector<std::string> skinNames;
std::vector<RenderBatch> renderBatches;
std::string error;
};
RuntimeContext* context(DtsRuntimeHandle handle) { return static_cast<RuntimeContext*>(handle); }
std::string lowercaseExtension(const std::string& path) {
const auto dot = path.find_last_of('.');
auto extension = dot == std::string::npos ? std::string{} : path.substr(dot);
std::transform(extension.begin(), extension.end(), extension.begin(),
[](unsigned char value) { return static_cast<char>(std::tolower(value)); });
return extension;
}
void collectMetadata(RuntimeContext& value) {
value.animationNames.clear();
value.animationDurations.clear();
auto& animations = value.skeletonData->getAnimations();
for (std::size_t index = 0; index < animations.size(); ++index) {
value.animationNames.emplace_back(animations[index]->getName().buffer());
value.animationDurations.push_back(animations[index]->getDuration());
}
value.skinNames.clear();
auto& skins = value.skeletonData->getSkins();
for (std::size_t index = 0; index < skins.size(); ++index)
value.skinNames.emplace_back(skins[index]->getName().buffer());
}
spine::AtlasRegion* regionFor(spine::RegionAttachment& attachment) {
#if defined(DTS_SPINE_41)
return static_cast<spine::AtlasRegion*>(attachment.getRegion());
#else
return static_cast<spine::AtlasRegion*>(attachment.getRendererObject());
#endif
}
spine::AtlasRegion* regionFor(spine::MeshAttachment& attachment) {
#if defined(DTS_SPINE_41)
return static_cast<spine::AtlasRegion*>(attachment.getRegion());
#else
return static_cast<spine::AtlasRegion*>(attachment.getRendererObject());
#endif
}
std::uint32_t packedColor(spine::Skeleton& skeleton, spine::Slot& slot,
spine::Color& attachment) {
const auto component = [](float value) {
return static_cast<std::uint32_t>(std::clamp(value, 0.0F, 1.0F) * 255.0F);
};
const auto& skeletonColor = skeleton.getColor();
const auto& slotColor = slot.getColor();
const auto red = component(skeletonColor.r * slotColor.r * attachment.r);
const auto green = component(skeletonColor.g * slotColor.g * attachment.g);
const auto blue = component(skeletonColor.b * slotColor.b * attachment.b);
const auto alpha = component(skeletonColor.a * slotColor.a * attachment.a);
return (alpha << 24) | (red << 16) | (green << 8) | blue;
}
void appendBatch(RuntimeContext& value, spine::Slot& slot, spine::Color& color,
spine::AtlasRegion* region, spine::Vector<float>& positions,
spine::Vector<float>& uvs, spine::Vector<unsigned short>& indices) {
if (!region || !region->page) return;
#if defined(DTS_SPINE_41)
auto* texture = static_cast<TextureRecord*>(region->page->texture);
#else
auto* texture = static_cast<TextureRecord*>(region->page->getRendererObject());
#endif
if (!texture) return;
auto* sourcePositions = &positions;
auto* sourceUvs = &uvs;
auto* sourceIndices = &indices;
if (value.clipper.isClipping()) {
value.clipper.clipTriangles(positions, indices, uvs, 2);
sourcePositions = &value.clipper.getClippedVertices();
sourceUvs = &value.clipper.getClippedUVs();
sourceIndices = &value.clipper.getClippedTriangles();
}
RenderBatch batch;
const auto vertexCount = sourcePositions->size() / 2;
batch.vertices.reserve(vertexCount);
const auto packed = packedColor(*value.skeleton, slot, color);
for (std::size_t index = 0; index < vertexCount; ++index)
batch.vertices.push_back({(*sourcePositions)[index * 2], -(*sourcePositions)[index * 2 + 1],
(*sourceUvs)[index * 2], (*sourceUvs)[index * 2 + 1], packed});
batch.indices.assign(sourceIndices->buffer(), sourceIndices->buffer() + sourceIndices->size());
batch.texturePath = texture->path;
batch.blendMode = static_cast<std::uint32_t>(slot.getData().getBlendMode());
value.renderBatches.push_back(std::move(batch));
}
void collectDrawCommands(RuntimeContext& value) {
value.renderBatches.clear();
spine::Vector<float> positions;
spine::Vector<unsigned short> quadIndices;
quadIndices.add(0); quadIndices.add(1); quadIndices.add(2);
quadIndices.add(2); quadIndices.add(3); quadIndices.add(0);
auto& drawOrder = value.skeleton->getDrawOrder();
for (std::size_t index = 0; index < drawOrder.size(); ++index) {
auto& slot = *drawOrder[index];
auto* attachment = slot.getAttachment();
if (!attachment) {
value.clipper.clipEnd(slot);
continue;
}
if (attachment->getRTTI().isExactly(spine::RegionAttachment::rtti)) {
auto& region = *static_cast<spine::RegionAttachment*>(attachment);
positions.setSize(8, 0);
#if defined(DTS_SPINE_41)
region.computeWorldVertices(slot, positions, 0, 2);
#else
region.computeWorldVertices(slot.getBone(), positions, 0, 2);
#endif
appendBatch(value, slot, region.getColor(), regionFor(region),
positions, region.getUVs(), quadIndices);
} else if (attachment->getRTTI().isExactly(spine::MeshAttachment::rtti)) {
auto& mesh = *static_cast<spine::MeshAttachment*>(attachment);
positions.setSize(mesh.getWorldVerticesLength(), 0);
mesh.computeWorldVertices(slot, 0, mesh.getWorldVerticesLength(),
positions.buffer(), 0, 2);
appendBatch(value, slot, mesh.getColor(), regionFor(mesh),
positions, mesh.getUVs(), mesh.getTriangles());
} else if (attachment->getRTTI().isExactly(spine::ClippingAttachment::rtti)) {
value.clipper.clipStart(slot, static_cast<spine::ClippingAttachment*>(attachment));
}
value.clipper.clipEnd(slot);
}
value.clipper.clipEnd();
}
} // namespace
namespace spine {
SpineExtension* getDefaultExtension() { return new DefaultSpineExtension(); }
} // namespace spine
extern "C" {
uint32_t dtsRuntimeGetApiVersion() { return DTS_RUNTIME_API_VERSION; }
int32_t dtsRuntimeGetInfo(DtsRuntimeInfo* info) {
if (!info || info->structSize < sizeof(DtsRuntimeInfo)) return -1;
info->apiVersion = DTS_RUNTIME_API_VERSION;
info->spineVersionLine = DTS_SPINE_LINE;
info->adapterBuild = "preview-1";
info->capabilities = DTS_RUNTIME_CAP_SKELETON_METADATA | DTS_RUNTIME_CAP_DRAW_COMMANDS;
return 0;
}
DtsRuntimeHandle dtsRuntimeCreate() { return new RuntimeContext; }
void dtsRuntimeDestroy(DtsRuntimeHandle handle) { delete context(handle); }
int32_t dtsRuntimeLoad(DtsRuntimeHandle handle, const DtsLoadRequest* request) {
auto* value = context(handle);
if (!value || !request || request->structSize < sizeof(DtsLoadRequest)
|| !request->skeletonPathUtf8 || !request->atlasPathUtf8) return -1;
value->error.clear();
value->renderBatches.clear();
value->animationState.reset();
value->animationStateData.reset();
value->skeleton.reset();
value->skeletonData.reset();
value->atlas.reset();
value->atlas = std::make_unique<spine::Atlas>(request->atlasPathUtf8, &value->textureLoader, true);
const float scale = request->scale > 0 ? request->scale : 1.0F;
if (lowercaseExtension(request->skeletonPathUtf8) == ".json") {
spine::SkeletonJson loader(value->atlas.get());
loader.setScale(scale);
value->skeletonData.reset(loader.readSkeletonDataFile(request->skeletonPathUtf8));
if (!value->skeletonData) value->error = loader.getError().buffer();
} else {
spine::SkeletonBinary loader(value->atlas.get());
loader.setScale(scale);
value->skeletonData.reset(loader.readSkeletonDataFile(request->skeletonPathUtf8));
if (!value->skeletonData) value->error = loader.getError().buffer();
}
if (!value->skeletonData) {
if (value->error.empty()) value->error = "Spine Runtime could not load skeleton data.";
return -2;
}
collectMetadata(*value);
value->skeleton = std::make_unique<spine::Skeleton>(value->skeletonData.get());
value->animationStateData = std::make_unique<spine::AnimationStateData>(value->skeletonData.get());
value->animationState = std::make_unique<spine::AnimationState>(value->animationStateData.get());
if (!value->animationNames.empty())
value->animationState->setAnimation(0, value->animationNames.front().c_str(), true);
value->animationState->apply(*value->skeleton);
value->skeleton->updateWorldTransform();
collectDrawCommands(*value);
return 0;
}
int32_t dtsRuntimeGetSkeletonInfo(DtsRuntimeHandle handle, DtsSkeletonInfo* info) {
auto* value = context(handle);
if (!value || !value->skeletonData || !info || info->structSize < sizeof(DtsSkeletonInfo)) return -1;
auto* data = value->skeletonData.get();
info->boneCount = static_cast<uint32_t>(data->getBones().size());
info->slotCount = static_cast<uint32_t>(data->getSlots().size());
info->skinCount = static_cast<uint32_t>(value->skinNames.size());
info->animationCount = static_cast<uint32_t>(value->animationNames.size());
info->eventCount = static_cast<uint32_t>(data->getEvents().size());
info->constraintCount = static_cast<uint32_t>(data->getIkConstraints().size()
+ data->getTransformConstraints().size() + data->getPathConstraints().size());
return 0;
}
const char* dtsRuntimeGetAnimationName(DtsRuntimeHandle handle, uint32_t index) {
auto* value = context(handle);
return value && index < value->animationNames.size() ? value->animationNames[index].c_str() : nullptr;
}
float dtsRuntimeGetAnimationDuration(DtsRuntimeHandle handle, uint32_t index) {
auto* value = context(handle);
return value && index < value->animationDurations.size() ? value->animationDurations[index] : 0.0F;
}
const char* dtsRuntimeGetSkinName(DtsRuntimeHandle handle, uint32_t index) {
auto* value = context(handle);
return value && index < value->skinNames.size() ? value->skinNames[index].c_str() : nullptr;
}
const char* dtsRuntimeGetLastError(DtsRuntimeHandle handle) {
auto* value = context(handle);
return value ? value->error.c_str() : "Invalid runtime handle.";
}
int32_t dtsRuntimeSetAnimation(DtsRuntimeHandle handle, const char* name, int32_t loop) {
auto* value = context(handle);
if (!value || !value->animationState || !name) return -1;
if (!value->skeletonData->findAnimation(name)) {
value->error = "Animation was not found.";
return -2;
}
value->skeleton->setToSetupPose();
value->animationState->setAnimation(0, name, loop != 0);
return 0;
}
int32_t dtsRuntimeUpdate(DtsRuntimeHandle handle, float seconds) {
auto* value = context(handle);
if (!value || !value->animationState || !value->skeleton) return -1;
value->animationState->update(std::max(0.0F, seconds));
value->animationState->apply(*value->skeleton);
value->skeleton->updateWorldTransform();
collectDrawCommands(*value);
return 0;
}
uint32_t dtsRuntimeGetDrawCommandCount(DtsRuntimeHandle handle) {
auto* value = context(handle);
return value ? static_cast<uint32_t>(value->renderBatches.size()) : 0;
}
int32_t dtsRuntimeGetDrawCommand(DtsRuntimeHandle handle, uint32_t index, DtsDrawCommand* command) {
auto* value = context(handle);
if (!value || index >= value->renderBatches.size() || !command
|| command->structSize < sizeof(DtsDrawCommand)) return -1;
const auto& batch = value->renderBatches[index];
command->vertices = batch.vertices.data();
command->vertexCount = static_cast<uint32_t>(batch.vertices.size());
command->indices = batch.indices.data();
command->indexCount = static_cast<uint32_t>(batch.indices.size());
command->texturePathUtf8 = batch.texturePath.c_str();
command->blendMode = batch.blendMode;
return 0;
}
} // extern "C"
@@ -0,0 +1,2 @@
#define DTS_SPINE_LINE "3.8"
#include "../common/LegacyRuntimeAdapter.inl"
@@ -0,0 +1,2 @@
#define DTS_SPINE_LINE "4.0"
#include "../common/LegacyRuntimeAdapter.inl"
@@ -0,0 +1,3 @@
#define DTS_SPINE_LINE "4.1"
#define DTS_SPINE_41
#include "../common/LegacyRuntimeAdapter.inl"
@@ -0,0 +1,213 @@
#include "RuntimeApi.h"
#include <spine/Animation.h>
#include <spine/AnimationState.h>
#include <spine/AnimationStateData.h>
#include <spine/Atlas.h>
#include <spine/Extension.h>
#include <spine/Skeleton.h>
#include <spine/SkeletonBinary.h>
#include <spine/SkeletonData.h>
#include <spine/SkeletonJson.h>
#include <spine/SkeletonRenderer.h>
#include <spine/Skin.h>
#include <spine/TextureLoader.h>
#include <algorithm>
#include <cctype>
#include <cstddef>
#include <memory>
#include <string>
#include <vector>
namespace {
struct TextureRecord { std::string path; };
class MetadataTextureLoader final : public spine::TextureLoader {
public:
void load(spine::AtlasPage& page, const spine::String& path) override {
page.texture = new TextureRecord{path.buffer()};
}
void unload(void* texture) override { delete static_cast<TextureRecord*>(texture); }
};
struct RenderBatch {
std::vector<DtsRenderVertex> vertices;
std::vector<std::uint16_t> indices;
std::string texturePath;
std::uint32_t blendMode = 0;
};
struct RuntimeContext {
MetadataTextureLoader textureLoader;
std::unique_ptr<spine::Atlas> atlas;
std::unique_ptr<spine::SkeletonData> skeletonData;
std::unique_ptr<spine::Skeleton> skeleton;
std::unique_ptr<spine::AnimationStateData> animationStateData;
std::unique_ptr<spine::AnimationState> animationState;
std::unique_ptr<spine::SkeletonRenderer> renderer;
std::vector<std::string> animationNames;
std::vector<float> animationDurations;
std::vector<std::string> skinNames;
std::vector<RenderBatch> renderBatches;
std::string error;
};
RuntimeContext* context(DtsRuntimeHandle handle) { return static_cast<RuntimeContext*>(handle); }
std::string lowercaseExtension(const std::string& path) {
const auto dot = path.find_last_of('.');
auto extension = dot == std::string::npos ? std::string{} : path.substr(dot);
std::transform(extension.begin(), extension.end(), extension.begin(),
[](unsigned char value) { return static_cast<char>(std::tolower(value)); });
return extension;
}
void collectMetadata(RuntimeContext& value) {
value.animationNames.clear();
value.animationDurations.clear();
auto& animations = value.skeletonData->getAnimations();
for (std::size_t index = 0; index < animations.size(); ++index) {
value.animationNames.emplace_back(animations[index]->getName().buffer());
value.animationDurations.push_back(animations[index]->getDuration());
}
value.skinNames.clear();
auto& skins = value.skeletonData->getSkins();
for (std::size_t index = 0; index < skins.size(); ++index)
value.skinNames.emplace_back(skins[index]->getName().buffer());
}
void collectDrawCommands(RuntimeContext& value) {
value.renderBatches.clear();
for (auto* command = value.renderer->render(*value.skeleton); command; command = command->next) {
RenderBatch batch;
batch.blendMode = static_cast<std::uint32_t>(command->blendMode);
if (const auto* texture = static_cast<TextureRecord*>(command->texture))
batch.texturePath = texture->path;
for (int index = 0; index < command->numVertices; ++index)
batch.vertices.push_back({command->positions[index * 2], -command->positions[index * 2 + 1],
command->uvs[index * 2], command->uvs[index * 2 + 1], command->colors[index]});
batch.indices.assign(command->indices, command->indices + command->numIndices);
value.renderBatches.push_back(std::move(batch));
}
}
} // namespace
namespace spine {
SpineExtension* getDefaultExtension() { return new DefaultSpineExtension(); }
} // namespace spine
extern "C" {
uint32_t dtsRuntimeGetApiVersion() { return DTS_RUNTIME_API_VERSION; }
int32_t dtsRuntimeGetInfo(DtsRuntimeInfo* info) {
if (!info || info->structSize < sizeof(DtsRuntimeInfo)) return -1;
info->apiVersion = DTS_RUNTIME_API_VERSION;
info->spineVersionLine = "4.2";
info->adapterBuild = "preview-1";
info->capabilities = DTS_RUNTIME_CAP_SKELETON_METADATA | DTS_RUNTIME_CAP_DRAW_COMMANDS;
return 0;
}
DtsRuntimeHandle dtsRuntimeCreate() { return new RuntimeContext; }
void dtsRuntimeDestroy(DtsRuntimeHandle handle) { delete context(handle); }
int32_t dtsRuntimeLoad(DtsRuntimeHandle handle, const DtsLoadRequest* request) {
auto* value = context(handle);
if (!value || !request || request->structSize < sizeof(DtsLoadRequest)
|| !request->skeletonPathUtf8 || !request->atlasPathUtf8) return -1;
value->error.clear();
value->renderBatches.clear();
value->renderer.reset();
value->animationState.reset();
value->animationStateData.reset();
value->skeleton.reset();
value->skeletonData.reset();
value->atlas.reset();
value->atlas = std::make_unique<spine::Atlas>(request->atlasPathUtf8, &value->textureLoader, true);
const float scale = request->scale > 0 ? request->scale : 1.0F;
if (lowercaseExtension(request->skeletonPathUtf8) == ".json") {
spine::SkeletonJson loader(value->atlas.get());
loader.setScale(scale);
value->skeletonData.reset(loader.readSkeletonDataFile(request->skeletonPathUtf8));
if (!value->skeletonData) value->error = loader.getError().buffer();
} else {
spine::SkeletonBinary loader(value->atlas.get());
loader.setScale(scale);
value->skeletonData.reset(loader.readSkeletonDataFile(request->skeletonPathUtf8));
if (!value->skeletonData) value->error = loader.getError().buffer();
}
if (!value->skeletonData) {
if (value->error.empty()) value->error = "Spine Runtime could not load skeleton data.";
return -2;
}
collectMetadata(*value);
value->skeleton = std::make_unique<spine::Skeleton>(value->skeletonData.get());
value->animationStateData = std::make_unique<spine::AnimationStateData>(value->skeletonData.get());
value->animationState = std::make_unique<spine::AnimationState>(value->animationStateData.get());
value->renderer = std::make_unique<spine::SkeletonRenderer>();
if (!value->animationNames.empty())
value->animationState->setAnimation(0, value->animationNames.front().c_str(), true);
value->animationState->apply(*value->skeleton);
value->skeleton->updateWorldTransform(spine::Physics_Update);
collectDrawCommands(*value);
return 0;
}
int32_t dtsRuntimeGetSkeletonInfo(DtsRuntimeHandle handle, DtsSkeletonInfo* info) {
auto* value = context(handle);
if (!value || !value->skeletonData || !info || info->structSize < sizeof(DtsSkeletonInfo)) return -1;
auto* data = value->skeletonData.get();
info->boneCount = static_cast<uint32_t>(data->getBones().size());
info->slotCount = static_cast<uint32_t>(data->getSlots().size());
info->skinCount = static_cast<uint32_t>(value->skinNames.size());
info->animationCount = static_cast<uint32_t>(value->animationNames.size());
info->eventCount = static_cast<uint32_t>(data->getEvents().size());
info->constraintCount = static_cast<uint32_t>(data->getIkConstraints().size()
+ data->getTransformConstraints().size() + data->getPathConstraints().size()
+ data->getPhysicsConstraints().size());
return 0;
}
const char* dtsRuntimeGetAnimationName(DtsRuntimeHandle handle, uint32_t index) {
auto* value = context(handle);
return value && index < value->animationNames.size() ? value->animationNames[index].c_str() : nullptr;
}
float dtsRuntimeGetAnimationDuration(DtsRuntimeHandle handle, uint32_t index) {
auto* value = context(handle);
return value && index < value->animationDurations.size() ? value->animationDurations[index] : 0.0F;
}
const char* dtsRuntimeGetSkinName(DtsRuntimeHandle handle, uint32_t index) {
auto* value = context(handle);
return value && index < value->skinNames.size() ? value->skinNames[index].c_str() : nullptr;
}
const char* dtsRuntimeGetLastError(DtsRuntimeHandle handle) {
auto* value = context(handle);
return value ? value->error.c_str() : "Invalid runtime handle.";
}
int32_t dtsRuntimeSetAnimation(DtsRuntimeHandle handle, const char* name, int32_t loop) {
auto* value = context(handle);
if (!value || !value->animationState || !name) return -1;
if (!value->skeletonData->findAnimation(name)) {
value->error = "Animation was not found.";
return -2;
}
value->skeleton->setToSetupPose();
value->animationState->setAnimation(0, name, loop != 0);
return 0;
}
int32_t dtsRuntimeUpdate(DtsRuntimeHandle handle, float seconds) {
auto* value = context(handle);
if (!value || !value->animationState || !value->skeleton || !value->renderer) return -1;
value->animationState->update(std::max(0.0F, seconds));
value->animationState->apply(*value->skeleton);
value->skeleton->updateWorldTransform(spine::Physics_Update);
collectDrawCommands(*value);
return 0;
}
uint32_t dtsRuntimeGetDrawCommandCount(DtsRuntimeHandle handle) {
auto* value = context(handle);
return value ? static_cast<uint32_t>(value->renderBatches.size()) : 0;
}
int32_t dtsRuntimeGetDrawCommand(DtsRuntimeHandle handle, uint32_t index, DtsDrawCommand* command) {
auto* value = context(handle);
if (!value || index >= value->renderBatches.size() || !command
|| command->structSize < sizeof(DtsDrawCommand)) return -1;
const auto& batch = value->renderBatches[index];
command->vertices = batch.vertices.data();
command->vertexCount = static_cast<uint32_t>(batch.vertices.size());
command->indices = batch.indices.data();
command->indexCount = static_cast<uint32_t>(batch.indices.size());
command->texturePathUtf8 = batch.texturePath.c_str();
command->blendMode = batch.blendMode;
return 0;
}
} // extern "C"
@@ -0,0 +1,272 @@
#include "RuntimeApi.h"
#include <spine/Animation.h>
#include <spine/AnimationState.h>
#include <spine/AnimationStateData.h>
#include <spine/Atlas.h>
#include <spine/Extension.h>
#include <spine/Skeleton.h>
#include <spine/SkeletonBinary.h>
#include <spine/SkeletonData.h>
#include <spine/SkeletonJson.h>
#include <spine/SkeletonRenderer.h>
#include <spine/Skin.h>
#include <spine/TextureLoader.h>
#include <algorithm>
#include <cctype>
#include <cstddef>
#include <memory>
#include <string>
#include <vector>
namespace {
struct TextureRecord {
std::string path;
};
class MetadataTextureLoader final : public spine::TextureLoader {
public:
void load(spine::AtlasPage& page, const spine::String& path) override {
page.texture = new TextureRecord{path.buffer()};
}
void unload(void* texture) override { delete static_cast<TextureRecord*>(texture); }
};
struct RenderBatch {
std::vector<DtsRenderVertex> vertices;
std::vector<std::uint16_t> indices;
std::string texturePath;
std::uint32_t blendMode = 0;
};
struct RuntimeContext {
MetadataTextureLoader textureLoader;
std::unique_ptr<spine::Atlas> atlas;
std::unique_ptr<spine::SkeletonData> skeletonData;
std::unique_ptr<spine::Skeleton> skeleton;
std::unique_ptr<spine::AnimationStateData> animationStateData;
std::unique_ptr<spine::AnimationState> animationState;
std::unique_ptr<spine::SkeletonRenderer> renderer;
std::vector<std::string> animationNames;
std::vector<float> animationDurations;
std::vector<std::string> skinNames;
std::vector<RenderBatch> renderBatches;
std::string error;
};
RuntimeContext* context(DtsRuntimeHandle handle) {
return static_cast<RuntimeContext*>(handle);
}
std::string lowercaseExtension(const std::string& path) {
const auto dot = path.find_last_of('.');
auto extension = dot == std::string::npos ? std::string{} : path.substr(dot);
std::transform(extension.begin(), extension.end(), extension.begin(),
[](unsigned char value) { return static_cast<char>(std::tolower(value)); });
return extension;
}
void collectMetadata(RuntimeContext& value) {
value.animationNames.clear();
value.animationDurations.clear();
auto& animations = value.skeletonData->getAnimations();
for (std::size_t index = 0; index < animations.size(); ++index) {
auto* animation = animations[index];
value.animationNames.emplace_back(animation->getName().buffer());
value.animationDurations.push_back(animation->getDuration());
}
value.skinNames.clear();
auto& skins = value.skeletonData->getSkins();
for (std::size_t index = 0; index < skins.size(); ++index) {
auto* skin = skins[index];
value.skinNames.emplace_back(skin->getName().buffer());
}
}
void collectDrawCommands(RuntimeContext& value) {
value.renderBatches.clear();
for (auto* command = value.renderer->render(*value.skeleton); command;
command = command->next) {
RenderBatch batch;
batch.blendMode = static_cast<std::uint32_t>(command->blendMode);
if (const auto* texture = static_cast<TextureRecord*>(command->texture))
batch.texturePath = texture->path;
batch.vertices.reserve(static_cast<std::size_t>(command->numVertices));
for (int index = 0; index < command->numVertices; ++index) {
batch.vertices.push_back({
command->positions[index * 2], command->positions[index * 2 + 1],
command->uvs[index * 2], command->uvs[index * 2 + 1],
command->colors[index]});
}
batch.indices.assign(command->indices, command->indices + command->numIndices);
value.renderBatches.push_back(std::move(batch));
}
}
} // namespace
namespace spine {
SpineExtension* getDefaultExtension() {
return new DefaultSpineExtension();
}
} // namespace spine
extern "C" {
uint32_t dtsRuntimeGetApiVersion() {
return DTS_RUNTIME_API_VERSION;
}
int32_t dtsRuntimeGetInfo(DtsRuntimeInfo* info) {
if (!info || info->structSize < sizeof(DtsRuntimeInfo)) {
return -1;
}
info->apiVersion = DTS_RUNTIME_API_VERSION;
info->spineVersionLine = "4.3";
info->adapterBuild = "prototype-2";
info->capabilities = DTS_RUNTIME_CAP_SKELETON_METADATA | DTS_RUNTIME_CAP_DRAW_COMMANDS;
return 0;
}
DtsRuntimeHandle dtsRuntimeCreate() {
return new RuntimeContext;
}
void dtsRuntimeDestroy(DtsRuntimeHandle handle) {
delete context(handle);
}
int32_t dtsRuntimeLoad(DtsRuntimeHandle handle, const DtsLoadRequest* request) {
auto* value = context(handle);
if (!value || !request || request->structSize < sizeof(DtsLoadRequest)
|| !request->skeletonPathUtf8 || !request->atlasPathUtf8) {
return -1;
}
value->error.clear();
value->renderBatches.clear();
value->renderer.reset();
value->animationState.reset();
value->animationStateData.reset();
value->skeleton.reset();
value->skeletonData.reset();
value->atlas.reset();
value->atlas = std::make_unique<spine::Atlas>(
request->atlasPathUtf8, &value->textureLoader, true);
const float scale = request->scale > 0 ? request->scale : 1.0F;
if (lowercaseExtension(request->skeletonPathUtf8) == ".json") {
spine::SkeletonJson loader(*value->atlas);
loader.setScale(scale);
value->skeletonData.reset(loader.readSkeletonDataFile(request->skeletonPathUtf8));
if (!value->skeletonData) value->error = loader.getError().buffer();
} else {
spine::SkeletonBinary loader(*value->atlas);
loader.setScale(scale);
value->skeletonData.reset(loader.readSkeletonDataFile(request->skeletonPathUtf8));
if (!value->skeletonData) value->error = loader.getError().buffer();
}
if (!value->skeletonData) {
if (value->error.empty()) value->error = "Spine Runtime could not load skeleton data.";
return -2;
}
collectMetadata(*value);
value->skeleton = std::make_unique<spine::Skeleton>(*value->skeletonData);
value->animationStateData = std::make_unique<spine::AnimationStateData>(
*value->skeletonData);
value->animationState = std::make_unique<spine::AnimationState>(
*value->animationStateData);
value->renderer = std::make_unique<spine::SkeletonRenderer>();
if (!value->animationNames.empty())
value->animationState->setAnimation(0, value->animationNames.front().c_str(), true);
value->animationState->apply(*value->skeleton);
value->skeleton->updateWorldTransform(spine::Physics_Update);
collectDrawCommands(*value);
return 0;
}
int32_t dtsRuntimeGetSkeletonInfo(DtsRuntimeHandle handle, DtsSkeletonInfo* info) {
auto* value = context(handle);
if (!value || !value->skeletonData || !info || info->structSize < sizeof(DtsSkeletonInfo)) {
return -1;
}
info->boneCount = static_cast<uint32_t>(value->skeletonData->getBones().size());
info->slotCount = static_cast<uint32_t>(value->skeletonData->getSlots().size());
info->skinCount = static_cast<uint32_t>(value->skinNames.size());
info->animationCount = static_cast<uint32_t>(value->animationNames.size());
info->eventCount = static_cast<uint32_t>(value->skeletonData->getEvents().size());
info->constraintCount = static_cast<uint32_t>(value->skeletonData->getConstraints().size());
return 0;
}
const char* dtsRuntimeGetAnimationName(DtsRuntimeHandle handle, uint32_t index) {
auto* value = context(handle);
return value && index < value->animationNames.size()
? value->animationNames[index].c_str() : nullptr;
}
float dtsRuntimeGetAnimationDuration(DtsRuntimeHandle handle, uint32_t index) {
auto* value = context(handle);
return value && index < value->animationDurations.size()
? value->animationDurations[index] : 0.0F;
}
const char* dtsRuntimeGetSkinName(DtsRuntimeHandle handle, uint32_t index) {
auto* value = context(handle);
return value && index < value->skinNames.size() ? value->skinNames[index].c_str() : nullptr;
}
const char* dtsRuntimeGetLastError(DtsRuntimeHandle handle) {
auto* value = context(handle);
return value ? value->error.c_str() : "Invalid runtime handle.";
}
int32_t dtsRuntimeSetAnimation(
DtsRuntimeHandle handle, const char* animationNameUtf8, int32_t loop) {
auto* value = context(handle);
if (!value || !value->animationState || !animationNameUtf8) return -1;
if (!value->skeletonData->findAnimation(animationNameUtf8)) {
value->error = "Animation was not found.";
return -2;
}
value->skeleton->setupPose();
value->animationState->setAnimation(0, animationNameUtf8, loop != 0);
return 0;
}
int32_t dtsRuntimeUpdate(DtsRuntimeHandle handle, float deltaSeconds) {
auto* value = context(handle);
if (!value || !value->animationState || !value->skeleton || !value->renderer) return -1;
value->animationState->update(std::max(0.0F, deltaSeconds));
value->animationState->apply(*value->skeleton);
value->skeleton->updateWorldTransform(spine::Physics_Update);
collectDrawCommands(*value);
return 0;
}
uint32_t dtsRuntimeGetDrawCommandCount(DtsRuntimeHandle handle) {
auto* value = context(handle);
return value ? static_cast<uint32_t>(value->renderBatches.size()) : 0;
}
int32_t dtsRuntimeGetDrawCommand(
DtsRuntimeHandle handle, uint32_t index, DtsDrawCommand* command) {
auto* value = context(handle);
if (!value || index >= value->renderBatches.size() || !command
|| command->structSize < sizeof(DtsDrawCommand)) return -1;
const auto& batch = value->renderBatches[index];
command->vertices = batch.vertices.data();
command->vertexCount = static_cast<uint32_t>(batch.vertices.size());
command->indices = batch.indices.data();
command->indexCount = static_cast<uint32_t>(batch.indices.size());
command->texturePathUtf8 = batch.texturePath.c_str();
command->blendMode = batch.blendMode;
return 0;
}
} // extern "C"
+90
View File
@@ -0,0 +1,90 @@
#include "editor/SpineEditorLocator.hpp"
#include "editor/SpineProbe.hpp"
#include "platform/ProcessRunner.hpp"
#include "scan/SpineDataVersionDetector.hpp"
#include <chrono>
#include <filesystem>
#include <iostream>
#include <string>
namespace {
void printProbe(const dts::EditorProbeResult& result) {
std::cout << "path: " << result.executable.string() << '\n';
std::cout << "status: " << dts::toString(result.status) << '\n';
if (result.launcherVersion) {
std::cout << "launcher: " << result.launcherVersion->toString() << '\n';
}
if (result.editorVersion) {
std::cout << "editor: " << result.editorVersion->toString() << '\n';
}
if (!result.edition.empty()) {
std::cout << "edition: " << result.edition << '\n';
}
std::cout << "message: " << result.message << '\n';
}
void printUsage() {
std::cout << "DateToSpine technical prototype\n"
<< "Usage:\n"
<< " datetospine-cli detect\n"
<< " datetospine-cli probe <Spine.app|executable> [timeout-ms]\n"
<< " datetospine-cli inspect-data <skeleton.json|skeleton.skel>\n";
}
} // namespace
int main(int argc, char** argv) {
if (argc < 2) {
printUsage();
return 2;
}
const std::string command = argv[1];
dts::SpineEditorLocator locator;
if (command == "detect") {
const auto candidates = locator.discover();
for (const auto& candidate : candidates) {
std::cout << candidate.string() << '\n';
}
return candidates.empty() ? 1 : 0;
}
if (command == "probe" && argc >= 3) {
const auto executable = locator.normalize(std::filesystem::path(argv[2]));
if (!executable) {
std::cerr << "The supplied path does not contain a Spine executable.\n";
return 1;
}
auto timeout = std::chrono::milliseconds(10000);
if (argc >= 4) {
try {
timeout = std::chrono::milliseconds(std::stoll(argv[3]));
} catch (...) {
std::cerr << "Invalid timeout.\n";
return 2;
}
}
auto runner = dts::createDefaultProcessRunner();
dts::SpineProbe probe(*runner);
const auto result = probe.inspect(*executable, timeout);
printProbe(result);
return result.status == dts::EditorProbeStatus::Available ? 0 : 1;
}
if (command == "inspect-data" && argc >= 3) {
const auto result = dts::SpineDataVersionDetector{}.inspect(argv[2]);
std::cout << "format: " << dts::toString(result.format) << '\n';
std::cout << "status: " << dts::toString(result.status) << '\n';
if (result.version) {
std::cout << "version: " << result.version->toString() << '\n';
}
std::cout << "message: " << result.message << '\n';
return result.status == dts::VersionDetectionStatus::Detected ? 0 : 1;
}
printUsage();
return 2;
}
+35
View File
@@ -0,0 +1,35 @@
#include "ui/MainWindow.hpp"
#include <QApplication>
#include <QCoreApplication>
#include <QPixmap>
#include <QTimer>
int main(int argc, char** argv) {
QApplication application(argc, argv);
QCoreApplication::setApplicationName("DateToSpine");
QCoreApplication::setOrganizationName("DateToSpine");
dts::MainWindow window;
window.show();
const auto arguments = application.arguments();
for (int index = 1; index < arguments.size(); ++index) {
if (arguments[index] == "--screenshot") {
++index;
} else if (!arguments[index].startsWith('-')) {
window.openResourcePath(arguments[index]);
}
}
const int screenshotOption = arguments.indexOf("--screenshot");
if (screenshotOption >= 0 && screenshotOption + 1 < arguments.size()) {
const auto screenshotPath = arguments[screenshotOption + 1];
QTimer::singleShot(250, &application, [&application, &window, screenshotPath] {
window.grab().save(screenshotPath);
application.quit();
});
}
if (application.arguments().contains("--smoke-test")) {
QTimer::singleShot(100, &application, &QCoreApplication::quit);
}
return application.exec();
}
+173
View File
@@ -0,0 +1,173 @@
#include "atlas/AtlasParser.hpp"
#include <algorithm>
#include <charconv>
#include <cctype>
#include <fstream>
#include <sstream>
namespace dts {
namespace {
std::string trim(std::string value) {
const auto isSpace = [](unsigned char character) { return std::isspace(character) != 0; };
value.erase(value.begin(), std::find_if_not(value.begin(), value.end(), isSpace));
value.erase(std::find_if_not(value.rbegin(), value.rend(), isSpace).base(), value.end());
return value;
}
std::vector<std::string> splitValues(const std::string& value) {
std::vector<std::string> values;
std::stringstream stream(value);
std::string item;
while (std::getline(stream, item, ',')) {
values.push_back(trim(item));
}
return values;
}
std::optional<std::vector<int>> parseIntegers(const std::string& value) {
std::vector<int> integers;
for (const auto& item : splitValues(value)) {
int number = 0;
const auto result = std::from_chars(item.data(), item.data() + item.size(), number);
if (result.ec != std::errc{} || result.ptr != item.data() + item.size()) {
return std::nullopt;
}
integers.push_back(number);
}
return integers;
}
bool isPageKey(const std::string& key) {
return key == "size" || key == "format" || key == "filter" || key == "repeat"
|| key == "pma" || key == "scale";
}
bool parseBoolean(const std::string& value) {
return value == "true" || value == "yes" || value == "1";
}
void applyPageProperty(AtlasPage& page, const std::string& key, const std::string& value) {
if (key == "size") {
page.size = parseIntegers(value).value_or(std::vector<int>{});
} else if (key == "format") {
page.format = value;
} else if (key == "filter") {
page.filter = value;
} else if (key == "repeat") {
page.repeat = value;
} else if (key == "pma") {
page.premultipliedAlpha = parseBoolean(value);
} else if (key == "scale") {
try {
page.scale = std::stod(value);
} catch (...) {
page.scale.reset();
}
}
}
void applyRegionProperty(AtlasRegion& region, const std::string& key, const std::string& value) {
if (key == "rotate") {
region.rotated = parseBoolean(value) || value == "90" || value == "270";
} else if (key == "bounds") {
region.bounds = parseIntegers(value).value_or(std::vector<int>{});
} else if (key == "xy") {
region.position = parseIntegers(value).value_or(std::vector<int>{});
} else if (key == "size") {
region.size = parseIntegers(value).value_or(std::vector<int>{});
} else if (key == "orig") {
region.originalSize = parseIntegers(value).value_or(std::vector<int>{});
} else if (key == "offset" || key == "offsets") {
region.offsets = parseIntegers(value).value_or(std::vector<int>{});
} else if (key == "split") {
region.split = parseIntegers(value).value_or(std::vector<int>{});
} else if (key == "pad") {
region.pad = parseIntegers(value).value_or(std::vector<int>{});
} else if (key == "index") {
const auto values = parseIntegers(value);
if (values && !values->empty()) {
region.index = values->front();
}
}
}
} // namespace
AtlasParseResult AtlasParser::parseFile(const std::filesystem::path& path) const {
std::ifstream input(path, std::ios::binary);
if (!input) {
return {std::nullopt, "Atlas file could not be opened.", 0};
}
std::ostringstream contents;
contents << input.rdbuf();
return parseText(contents.str());
}
AtlasParseResult AtlasParser::parseText(const std::string& text) const {
AtlasDocument document;
AtlasPage* page = nullptr;
AtlasRegion* region = nullptr;
bool expectPage = true;
std::istringstream input(text);
std::string rawLine;
std::size_t lineNumber = 0;
while (std::getline(input, rawLine)) {
++lineNumber;
if (!rawLine.empty() && rawLine.back() == '\r') {
rawLine.pop_back();
}
const auto line = trim(rawLine);
if (line.empty()) {
expectPage = true;
page = nullptr;
region = nullptr;
continue;
}
const auto colon = line.find(':');
if (colon == std::string::npos) {
if (expectPage || page == nullptr) {
AtlasPage nextPage;
nextPage.imagePath = std::filesystem::path(line);
document.pages.push_back(std::move(nextPage));
page = &document.pages.back();
region = nullptr;
expectPage = false;
} else {
AtlasRegion nextRegion;
nextRegion.name = line;
page->regions.push_back(std::move(nextRegion));
region = &page->regions.back();
}
continue;
}
if (!page) {
return {std::nullopt, "Atlas property appears before a page name.", lineNumber};
}
const auto key = trim(line.substr(0, colon));
const auto value = trim(line.substr(colon + 1));
if (!region && isPageKey(key)) {
applyPageProperty(*page, key, value);
} else if (region) {
applyRegionProperty(*region, key, value);
} else {
document.warnings.push_back("Unknown page property at line " + std::to_string(lineNumber));
}
}
if (document.pages.empty()) {
return {std::nullopt, "Atlas does not contain any pages.", 0};
}
for (const auto& atlasPage : document.pages) {
if (atlasPage.imagePath.empty()) {
return {std::nullopt, "Atlas contains an empty page name.", 0};
}
}
return {std::move(document), {}, 0};
}
} // namespace dts
+52
View File
@@ -0,0 +1,52 @@
#pragma once
#include <filesystem>
#include <optional>
#include <string>
#include <utility>
#include <vector>
namespace dts {
struct AtlasRegion {
std::string name;
bool rotated = false;
std::vector<int> bounds;
std::vector<int> position;
std::vector<int> size;
std::vector<int> originalSize;
std::vector<int> offsets;
std::vector<int> split;
std::vector<int> pad;
std::optional<int> index;
};
struct AtlasPage {
std::filesystem::path imagePath;
std::vector<int> size;
std::string format;
std::string filter;
std::string repeat;
std::optional<bool> premultipliedAlpha;
std::optional<double> scale;
std::vector<AtlasRegion> regions;
};
struct AtlasDocument {
std::vector<AtlasPage> pages;
std::vector<std::string> warnings;
};
struct AtlasParseResult {
std::optional<AtlasDocument> document;
std::string error;
std::size_t errorLine = 0;
};
class AtlasParser {
public:
[[nodiscard]] AtlasParseResult parseFile(const std::filesystem::path& path) const;
[[nodiscard]] AtlasParseResult parseText(const std::string& text) const;
};
} // namespace dts
@@ -0,0 +1,218 @@
#include "conversion/ConversionJob.hpp"
#include "atlas/AtlasParser.hpp"
#include "domain/SpineVersion.hpp"
#include "editor/SpineVersionAvailability.hpp"
#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
#include <QProcess>
#include <QRegularExpression>
#include <QTemporaryDir>
#include <cmath>
#include <filesystem>
namespace dts {
namespace {
std::filesystem::path localPath(const QString& value) {
#if defined(_WIN32)
return std::filesystem::path(value.toStdWString());
#else
return std::filesystem::path(value.toStdString());
#endif
}
QString uniqueDirectory(const QString& parent, const QString& name) {
QDir directory(parent);
QString candidate = directory.filePath(name);
for (int suffix = 2; QFileInfo::exists(candidate); ++suffix)
candidate = directory.filePath(QStringLiteral("%1-%2").arg(name).arg(suffix));
return candidate;
}
} // namespace
ConversionJob::ConversionJob(ConversionRequest request, QObject* parent)
: QObject(parent), request_(std::move(request)) {}
ConversionJob::~ConversionJob() = default;
void ConversionJob::start() {
QString error;
if (!prepare(error)) {
fail(error);
return;
}
startUnpack();
}
bool ConversionJob::prepare(QString& error) {
const auto version = SpineVersion::parse(request_.editorVersion.toStdString());
if (!version || !SpineVersionAvailability{}.isCached(*version)) {
error = tr("本地未缓存 Spine %1。为避免触发下载,转换已停止。")
.arg(request_.editorVersion);
return false;
}
if (!QFileInfo::exists(request_.editorPath)) {
error = tr("找不到已指定的 Spine Editor。请重新指定程序路径。");
return false;
}
const auto atlas = AtlasParser{}.parseFile(localPath(request_.atlasPath));
if (!atlas.document || atlas.document->pages.empty()) {
error = tr("Atlas 无法识别,不能确定官方导入比例。");
return false;
}
double scale = atlas.document->pages.front().scale.value_or(1.0);
if (!std::isfinite(scale) || scale <= 0.0) {
error = tr("Atlas 中的 scale 无效。");
return false;
}
for (const auto& page : atlas.document->pages) {
const double pageScale = page.scale.value_or(1.0);
if (!std::isfinite(pageScale) || std::abs(pageScale - scale) > 0.000001) {
error = tr("Atlas 的纹理页使用了不同 scale,无法生成尺寸一致的工程。");
return false;
}
}
importScale_ = QString::number(scale, 'g', 15);
QDir parent(request_.outputParent);
if (!parent.exists() && !parent.mkpath(QStringLiteral("."))) {
error = tr("无法创建输出目录。");
return false;
}
finalDirectory_ = uniqueDirectory(request_.outputParent, request_.assetName);
staging_ = std::make_unique<QTemporaryDir>(
parent.filePath(QStringLiteral(".datetospine-XXXXXX")));
if (!staging_->isValid()) {
error = tr("无法创建临时转换目录。");
return false;
}
QDir staging(staging_->path());
if (!staging.mkpath(QStringLiteral("images"))) {
error = tr("无法准备图片目录。");
return false;
}
projectPath_ = staging.filePath(request_.assetName + QStringLiteral(".spine"));
// The official unpacker reads `pma` from the Atlas. When conversion to
// straight alpha is requested, pass the source Atlas unchanged so Spine
// performs the conversion itself. Otherwise a temporary metadata copy
// disables that conversion while leaving all geometry fields intact.
unpackAtlasPath_ = request_.unpremultiplyAlpha
? request_.atlasPath
: staging.filePath(QStringLiteral("unpack.atlas"));
return writeUnpackAtlas(error);
}
bool ConversionJob::writeUnpackAtlas(QString& error) {
if (request_.unpremultiplyAlpha) return true;
QFile source(request_.atlasPath);
if (!source.open(QIODevice::ReadOnly)) {
error = tr("无法读取 Atlas 文件。");
return false;
}
auto contents = QString::fromUtf8(source.readAll());
source.close();
// Preserve packed pixel values when the user explicitly leaves PMA enabled.
// Pixel decoding, cropping and Alpha handling remain entirely inside Spine.
contents.replace(QRegularExpression(
QStringLiteral("(?m)^(\\s*pma\\s*:\\s*)true(\\s*)$")), QStringLiteral("\\1false\\2"));
QFile temporary(unpackAtlasPath_);
const auto encoded = contents.toUtf8();
if (!temporary.open(QIODevice::WriteOnly | QIODevice::Truncate)
|| temporary.write(encoded) != encoded.size()) {
error = tr("无法准备离线图集解包配置。");
return false;
}
return true;
}
void ConversionJob::startUnpack() {
stage_ = Stage::Unpacking;
process_ = new QProcess(this);
process_->setProcessChannelMode(QProcess::MergedChannels);
connect(process_, &QProcess::finished, this, &ConversionJob::processFinished);
connect(process_, &QProcess::errorOccurred, this, [this](QProcess::ProcessError error) {
if (error == QProcess::FailedToStart && stage_ != Stage::Done)
fail(tr("无法启动本机 Spine Editor。"));
});
process_->start(request_.editorPath, {
"-u", request_.editorVersion,
"-i", QFileInfo(request_.atlasPath).absolutePath(),
"-o", QDir(staging_->path()).filePath(QStringLiteral("images")),
"-c", unpackAtlasPath_});
}
void ConversionJob::startImport() {
stage_ = Stage::Importing;
process_->start(request_.editorPath, {
"-u", request_.editorVersion,
"-i", request_.skeletonPath,
// Numeric scale works with all supported Editor lines. Older Editors
// (notably 4.0) do not accept an Atlas path for this argument.
"-s", importScale_,
"-o", projectPath_,
"--import"});
}
bool ConversionJob::hasUnpackedImages() const {
QDirIterator iterator(QDir(staging_->path()).filePath(QStringLiteral("images")),
{"*.png", "*.jpg", "*.jpeg", "*.webp"}, QDir::Files,
QDirIterator::Subdirectories);
return iterator.hasNext();
}
void ConversionJob::processFinished(int exitCode) {
if (stage_ == Stage::Done) return;
const auto output = QString::fromUtf8(process_->readAll());
if (exitCode != 0) {
fail(stage_ == Stage::Unpacking
? tr("官方图集解包失败:\n%1").arg(output.right(1600))
: tr("Spine 工程导入失败:\n%1").arg(output.right(1600)));
return;
}
if (stage_ == Stage::Unpacking) {
if (!hasUnpackedImages()) {
fail(tr("官方图集解包未生成任何图片。"));
return;
}
if (unpackAtlasPath_ != request_.atlasPath) QFile::remove(unpackAtlasPath_);
startImport();
return;
}
if (stage_ == Stage::Importing) {
if (QFileInfo(projectPath_).size() <= 0) {
fail(tr("Spine Editor 未生成有效的 .spine 工程。"));
return;
}
publish();
}
}
void ConversionJob::publish() {
stage_ = Stage::Publishing;
const auto source = localPath(staging_->path());
const auto destination = localPath(finalDirectory_);
std::error_code error;
std::filesystem::rename(source, destination, error);
if (error) {
fail(tr("无法发布转换结果:%1").arg(QString::fromStdString(error.message())));
return;
}
staging_->setAutoRemove(false);
stage_ = Stage::Done;
emit finished(true, finalDirectory_, {});
}
void ConversionJob::fail(const QString& reason) {
if (stage_ == Stage::Done) return;
stage_ = Stage::Done;
if (process_) process_->kill();
emit finished(false, {}, reason);
}
} // namespace dts
@@ -0,0 +1,56 @@
#pragma once
#include <QObject>
#include <QString>
#include <memory>
class QProcess;
class QTemporaryDir;
namespace dts {
struct ConversionRequest {
QString editorPath;
QString editorVersion;
QString skeletonPath;
QString atlasPath;
QString outputParent;
QString assetName;
bool unpremultiplyAlpha = false;
};
class ConversionJob final : public QObject {
Q_OBJECT
public:
explicit ConversionJob(ConversionRequest request, QObject* parent = nullptr);
~ConversionJob() override;
void start();
signals:
void finished(bool success, const QString& outputDirectory, const QString& error);
private:
enum class Stage { Preparing, Unpacking, Importing, Publishing, Done };
bool prepare(QString& error);
bool writeUnpackAtlas(QString& error);
bool hasUnpackedImages() const;
void startUnpack();
void startImport();
void processFinished(int exitCode);
void fail(const QString& reason);
void publish();
ConversionRequest request_;
Stage stage_ = Stage::Preparing;
std::unique_ptr<QTemporaryDir> staging_;
QProcess* process_ = nullptr;
QString finalDirectory_;
QString projectPath_;
QString unpackAtlasPath_;
QString importScale_;
};
} // namespace dts
+71
View File
@@ -0,0 +1,71 @@
#include "domain/SpineVersion.hpp"
#include <charconv>
namespace dts {
namespace {
std::optional<int> parseNumber(std::string_view text) {
int value = 0;
const auto result = std::from_chars(text.data(), text.data() + text.size(), value);
if (result.ec != std::errc{} || result.ptr != text.data() + text.size()) {
return std::nullopt;
}
return value;
}
} // namespace
std::optional<SpineVersion> SpineVersion::parse(std::string_view text) {
const auto firstDot = text.find('.');
if (firstDot == std::string_view::npos) {
return std::nullopt;
}
const auto secondDot = text.find('.', firstDot + 1);
const auto majorValue = parseNumber(text.substr(0, firstDot));
const auto minorEnd = secondDot == std::string_view::npos ? text.size() : secondDot;
const auto minorValue = parseNumber(text.substr(firstDot + 1, minorEnd - firstDot - 1));
if (!majorValue || !minorValue) {
return std::nullopt;
}
SpineVersion version{*majorValue, *minorValue, 0, false};
if (secondDot != std::string_view::npos) {
auto patchText = text.substr(secondDot + 1);
const auto suffix = patchText.find_first_not_of("0123456789");
const auto numericPatch = patchText.substr(0, suffix);
if (numericPatch.empty() || (suffix != std::string_view::npos
&& patchText[suffix] != '-')) {
return std::nullopt;
}
const auto patchValue = parseNumber(numericPatch);
if (!patchValue) {
return std::nullopt;
}
version.patch = *patchValue;
version.hasPatch = true;
}
return version;
}
std::string SpineVersion::line() const {
return std::to_string(major) + "." + std::to_string(minor);
}
std::string SpineVersion::toString() const {
auto value = line();
if (hasPatch) {
value += "." + std::to_string(patch);
}
return value;
}
bool SpineVersion::isSupported() const {
if (major == 3 && minor == 8) {
return !hasPatch || patch >= 20;
}
return major == 4 && minor >= 0 && minor <= 3;
}
} // namespace dts
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include <optional>
#include <string>
#include <string_view>
namespace dts {
struct SpineVersion {
int major = 0;
int minor = 0;
int patch = 0;
bool hasPatch = false;
[[nodiscard]] static std::optional<SpineVersion> parse(std::string_view text);
[[nodiscard]] std::string line() const;
[[nodiscard]] std::string toString() const;
[[nodiscard]] bool isSupported() const;
friend bool operator==(const SpineVersion&, const SpineVersion&) = default;
};
} // namespace dts
@@ -0,0 +1,79 @@
#include "editor/SpineEditorLocator.hpp"
#include <algorithm>
#include <cstdlib>
#include <system_error>
namespace dts {
namespace fs = std::filesystem;
namespace {
void addUnique(std::vector<fs::path>& paths, const fs::path& candidate) {
std::error_code error;
if (!fs::is_regular_file(candidate, error)) {
return;
}
const auto canonical = fs::weakly_canonical(candidate, error);
const auto value = error ? candidate.lexically_normal() : canonical;
if (std::find(paths.begin(), paths.end(), value) == paths.end()) {
paths.push_back(value);
}
}
} // namespace
std::vector<fs::path> SpineEditorLocator::discover() const {
std::vector<fs::path> results;
#if defined(__APPLE__)
addUnique(results, "/Applications/Spine.app/Contents/MacOS/Spine");
if (const char* userHome = std::getenv("HOME")) {
addUnique(results, fs::path(userHome) / "Applications/Spine.app/Contents/MacOS/Spine");
}
#elif defined(_WIN32)
const auto addFromRoot = [&results](const char* variable) {
if (const char* root = std::getenv(variable)) {
addUnique(results, fs::path(root) / "Spine/Spine.com");
}
};
addFromRoot("ProgramFiles");
addFromRoot("ProgramFiles(x86)");
#endif
return results;
}
std::optional<fs::path> SpineEditorLocator::normalize(const fs::path& userPath) const {
std::error_code error;
auto candidate = userPath;
#if defined(__APPLE__)
if (candidate.extension() == ".app") {
candidate /= "Contents/MacOS/Spine";
} else if (fs::is_directory(candidate, error)) {
const auto appCandidate = candidate / "Spine.app/Contents/MacOS/Spine";
if (fs::is_regular_file(appCandidate, error)) {
candidate = appCandidate;
}
}
#elif defined(_WIN32)
if (fs::is_directory(candidate, error)) {
candidate /= "Spine.com";
} else if (candidate.extension() == ".exe") {
auto commandCandidate = candidate;
commandCandidate.replace_extension(".com");
if (fs::is_regular_file(commandCandidate, error)) {
candidate = commandCandidate;
}
}
#endif
if (!fs::is_regular_file(candidate, error)) {
return std::nullopt;
}
const auto canonical = fs::weakly_canonical(candidate, error);
return error ? std::optional(candidate.lexically_normal()) : std::optional(canonical);
}
} // namespace dts
@@ -0,0 +1,17 @@
#pragma once
#include <filesystem>
#include <optional>
#include <vector>
namespace dts {
class SpineEditorLocator {
public:
[[nodiscard]] std::vector<std::filesystem::path> discover() const;
[[nodiscard]] std::optional<std::filesystem::path> normalize(
const std::filesystem::path& userPath) const;
};
} // namespace dts
@@ -0,0 +1,82 @@
#include "editor/SpineOutputParser.hpp"
#include <regex>
namespace dts {
namespace {
std::optional<SpineVersion> firstVersion(
const std::string& text,
const std::regex& expression,
std::string* trailing = nullptr) {
std::smatch match;
if (!std::regex_search(text, match, expression)) {
return std::nullopt;
}
if (trailing && match.size() > 2) {
*trailing = match[2].str();
}
return SpineVersion::parse(match[1].str());
}
} // namespace
EditorProbeResult SpineOutputParser::parse(
const std::filesystem::path& executable,
const ProcessResult& process) const {
EditorProbeResult result;
result.executable = executable;
result.timedOut = process.timedOut;
result.exitCode = process.exitCode;
result.output = process.output;
if (!process.started) {
result.status = EditorProbeStatus::LaunchFailed;
result.message = process.error.empty() ? "Spine process could not be started." : process.error;
return result;
}
static const std::regex launcher(R"(Spine Launcher\s+(\d+\.\d+(?:\.\d+)?))");
static const std::regex launching(
R"((?:Launching:|Starting:)\s*Spine\s+(\d+\.\d+(?:\.\d+)?)(?:\s+([^\r\n]+))?)");
// MSVC's std::regex does not implement std::regex_constants::multiline,
// so instead of relying on the '^' line anchor with the multiline flag,
// search for "Spine <version>" anywhere (regex_search scans the whole text).
static const std::regex directEditor(
R"(Spine\s+(\d+\.\d+(?:\.\d+)?)(?:\s+([^\r\n]+))?)");
result.launcherVersion = firstVersion(process.output, launcher);
result.editorVersion = firstVersion(process.output, launching, &result.edition);
if (!result.editorVersion) {
result.editorVersion = firstVersion(process.output, directEditor, &result.edition);
}
if (!result.launcherVersion && !result.editorVersion) {
result.status = EditorProbeStatus::UnrecognizedOutput;
result.message = "Process started, but its output was not recognized as Spine.";
} else if (process.timedOut) {
result.status = EditorProbeStatus::ObservedButTimedOut;
result.message = "Spine was detected, but the version probe timed out.";
} else if (process.exitCode == 0) {
result.status = EditorProbeStatus::Available;
result.message = "Spine CLI probe succeeded.";
} else {
result.status = EditorProbeStatus::LaunchFailed;
result.message = "Spine was detected, but the process returned a failure code.";
}
return result;
}
std::string toString(EditorProbeStatus status) {
switch (status) {
case EditorProbeStatus::Available: return "available";
case EditorProbeStatus::ObservedButTimedOut: return "observed-but-timed-out";
case EditorProbeStatus::PathMissing: return "path-missing";
case EditorProbeStatus::LaunchFailed: return "launch-failed";
case EditorProbeStatus::UnrecognizedOutput: return "unrecognized-output";
}
return "unknown";
}
} // namespace dts
@@ -0,0 +1,42 @@
#pragma once
#include "domain/SpineVersion.hpp"
#include "platform/ProcessRunner.hpp"
#include <filesystem>
#include <optional>
#include <string>
namespace dts {
enum class EditorProbeStatus {
Available,
ObservedButTimedOut,
PathMissing,
LaunchFailed,
UnrecognizedOutput
};
struct EditorProbeResult {
std::filesystem::path executable;
EditorProbeStatus status = EditorProbeStatus::UnrecognizedOutput;
std::optional<SpineVersion> launcherVersion;
std::optional<SpineVersion> editorVersion;
std::string edition;
bool timedOut = false;
int exitCode = -1;
std::string output;
std::string message;
};
class SpineOutputParser {
public:
[[nodiscard]] EditorProbeResult parse(
const std::filesystem::path& executable,
const ProcessResult& process) const;
};
[[nodiscard]] std::string toString(EditorProbeStatus status);
} // namespace dts
+31
View File
@@ -0,0 +1,31 @@
#include "editor/SpineProbe.hpp"
#include <system_error>
namespace dts {
SpineProbe::SpineProbe(const IProcessRunner& processRunner)
: processRunner_(processRunner) {}
EditorProbeResult SpineProbe::inspect(
const std::filesystem::path& executable,
std::chrono::milliseconds timeout) const {
std::error_code error;
if (!std::filesystem::is_regular_file(executable, error)) {
EditorProbeResult result;
result.executable = executable;
result.status = EditorProbeStatus::PathMissing;
result.message = "Spine executable does not exist.";
return result;
}
ProcessRequest request;
request.program = executable;
request.arguments = {"--version"};
request.workingDirectory = executable.parent_path();
request.timeout = timeout;
return parser_.parse(executable, processRunner_.run(request));
}
} // namespace dts
+25
View File
@@ -0,0 +1,25 @@
#pragma once
#include "editor/SpineOutputParser.hpp"
#include "platform/ProcessRunner.hpp"
#include <chrono>
#include <filesystem>
namespace dts {
class SpineProbe {
public:
explicit SpineProbe(const IProcessRunner& processRunner);
[[nodiscard]] EditorProbeResult inspect(
const std::filesystem::path& executable,
std::chrono::milliseconds timeout = std::chrono::seconds(10)) const;
private:
const IProcessRunner& processRunner_;
SpineOutputParser parser_;
};
} // namespace dts
@@ -0,0 +1,64 @@
#include "editor/SpineVersionAvailability.hpp"
#include <algorithm>
#include <cstdlib>
#include <system_error>
namespace dts {
namespace fs = std::filesystem;
std::vector<fs::path> SpineVersionAvailability::defaultCacheRoots() const {
std::vector<fs::path> roots;
#if defined(__APPLE__)
if (const char* userHome = std::getenv("HOME"))
roots.emplace_back(fs::path(userHome) / "Library/Application Support/Spine/updates");
#elif defined(_WIN32)
if (const char* roaming = std::getenv("APPDATA"))
roots.emplace_back(fs::path(roaming) / "Spine/updates");
if (const char* local = std::getenv("LOCALAPPDATA"))
roots.emplace_back(fs::path(local) / "Spine/updates");
// Spine Launcher also keeps its update cache directly under the user's
// profile directory (e.g. C:\Users\<name>\Spine\updates).
if (const char* userProfile = std::getenv("USERPROFILE"))
roots.emplace_back(fs::path(userProfile) / "Spine/updates");
#endif
return roots;
}
std::vector<SpineVersion> SpineVersionAvailability::discover(
const std::vector<fs::path>& cacheRoots) const {
std::vector<SpineVersion> versions;
const auto roots = cacheRoots.empty() ? defaultCacheRoots() : cacheRoots;
for (const auto& root : roots) {
std::error_code error;
fs::directory_iterator iterator(root, error);
for (const auto& entry : iterator) {
if (!entry.is_regular_file(error) || entry.file_size(error) == 0) continue;
const auto parsed = SpineVersion::parse(entry.path().filename().string());
if (parsed && std::find(versions.begin(), versions.end(), *parsed) == versions.end())
versions.push_back(*parsed);
}
}
std::sort(versions.begin(), versions.end(), [](const auto& left, const auto& right) {
if (left.major != right.major) return left.major < right.major;
if (left.minor != right.minor) return left.minor < right.minor;
return left.patch < right.patch;
});
return versions;
}
std::optional<SpineVersion> SpineVersionAvailability::newestForLine(
std::string_view versionLine, const std::vector<fs::path>& cacheRoots) const {
std::optional<SpineVersion> result;
for (const auto& version : discover(cacheRoots))
if (version.line() == versionLine) result = version;
return result;
}
bool SpineVersionAvailability::isCached(
const SpineVersion& version, const std::vector<fs::path>& cacheRoots) const {
const auto versions = discover(cacheRoots);
return std::find(versions.begin(), versions.end(), version) != versions.end();
}
} // namespace dts
@@ -0,0 +1,25 @@
#pragma once
#include "domain/SpineVersion.hpp"
#include <filesystem>
#include <optional>
#include <string_view>
#include <vector>
namespace dts {
class SpineVersionAvailability {
public:
[[nodiscard]] std::vector<std::filesystem::path> defaultCacheRoots() const;
[[nodiscard]] std::vector<SpineVersion> discover(
const std::vector<std::filesystem::path>& cacheRoots = {}) const;
[[nodiscard]] std::optional<SpineVersion> newestForLine(
std::string_view versionLine,
const std::vector<std::filesystem::path>& cacheRoots = {}) const;
[[nodiscard]] bool isCached(
const SpineVersion& version,
const std::vector<std::filesystem::path>& cacheRoots = {}) const;
};
} // namespace dts
@@ -0,0 +1,35 @@
#pragma once
#include <chrono>
#include <filesystem>
#include <memory>
#include <string>
#include <vector>
namespace dts {
struct ProcessRequest {
std::filesystem::path program;
std::vector<std::string> arguments;
std::filesystem::path workingDirectory;
std::chrono::milliseconds timeout{10000};
};
struct ProcessResult {
bool started = false;
bool timedOut = false;
int exitCode = -1;
std::string output;
std::string error;
};
class IProcessRunner {
public:
virtual ~IProcessRunner() = default;
[[nodiscard]] virtual ProcessResult run(const ProcessRequest& request) const = 0;
};
[[nodiscard]] std::unique_ptr<IProcessRunner> createDefaultProcessRunner();
} // namespace dts
@@ -0,0 +1,134 @@
#include "platform/ProcessRunner.hpp"
#include <cerrno>
#include <chrono>
#include <csignal>
#include <cstring>
#include <fcntl.h>
#include <memory>
#include <poll.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <thread>
#include <unistd.h>
namespace dts {
namespace {
void appendAvailableOutput(int descriptor, std::string& output) {
char buffer[4096];
while (true) {
const auto count = ::read(descriptor, buffer, sizeof(buffer));
if (count > 0) {
output.append(buffer, static_cast<std::size_t>(count));
continue;
}
if (count < 0 && errno == EINTR) {
continue;
}
break;
}
}
class PosixProcessRunner final : public IProcessRunner {
public:
ProcessResult run(const ProcessRequest& request) const override {
ProcessResult result;
int outputPipe[2];
if (::pipe(outputPipe) != 0) {
result.error = std::strerror(errno);
return result;
}
const pid_t child = ::fork();
if (child < 0) {
result.error = std::strerror(errno);
::close(outputPipe[0]);
::close(outputPipe[1]);
return result;
}
if (child == 0) {
::setpgid(0, 0);
::close(outputPipe[0]);
::dup2(outputPipe[1], STDOUT_FILENO);
::dup2(outputPipe[1], STDERR_FILENO);
::close(outputPipe[1]);
if (!request.workingDirectory.empty()) {
::chdir(request.workingDirectory.c_str());
}
std::vector<std::string> values;
values.reserve(request.arguments.size() + 1);
values.push_back(request.program.string());
values.insert(values.end(), request.arguments.begin(), request.arguments.end());
std::vector<char*> arguments;
arguments.reserve(values.size() + 1);
for (auto& value : values) {
arguments.push_back(value.data());
}
arguments.push_back(nullptr);
::execv(request.program.c_str(), arguments.data());
::_exit(127);
}
result.started = true;
::setpgid(child, child);
::close(outputPipe[1]);
const auto flags = ::fcntl(outputPipe[0], F_GETFL, 0);
::fcntl(outputPipe[0], F_SETFL, flags | O_NONBLOCK);
const auto deadline = std::chrono::steady_clock::now() + request.timeout;
int status = 0;
bool finished = false;
while (!finished) {
appendAvailableOutput(outputPipe[0], result.output);
const auto waitResult = ::waitpid(child, &status, WNOHANG);
if (waitResult == child) {
finished = true;
break;
}
if (waitResult < 0) {
result.error = std::strerror(errno);
break;
}
if (std::chrono::steady_clock::now() >= deadline) {
result.timedOut = true;
::kill(-child, SIGTERM);
for (int attempt = 0; attempt < 25; ++attempt) {
if (::waitpid(child, &status, WNOHANG) == child) {
finished = true;
break;
}
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
if (!finished) {
::kill(-child, SIGKILL);
::waitpid(child, &status, 0);
}
break;
}
pollfd descriptor{outputPipe[0], POLLIN, 0};
::poll(&descriptor, 1, 25);
}
appendAvailableOutput(outputPipe[0], result.output);
::close(outputPipe[0]);
if (WIFEXITED(status)) {
result.exitCode = WEXITSTATUS(status);
} else if (WIFSIGNALED(status)) {
result.exitCode = 128 + WTERMSIG(status);
}
return result;
}
};
} // namespace
std::unique_ptr<IProcessRunner> createDefaultProcessRunner() {
return std::make_unique<PosixProcessRunner>();
}
} // namespace dts
@@ -0,0 +1,132 @@
#include "platform/ProcessRunner.hpp"
#define NOMINMAX
#include <windows.h>
#include <memory>
#include <sstream>
namespace dts {
namespace {
std::wstring widen(const std::string& value) {
if (value.empty()) {
return {};
}
const int size = MultiByteToWideChar(CP_UTF8, 0, value.data(),
static_cast<int>(value.size()), nullptr, 0);
std::wstring output(static_cast<std::size_t>(size), L'\0');
MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast<int>(value.size()),
output.data(), size);
return output;
}
std::wstring quote(const std::wstring& value) {
if (value.find_first_of(L" \t\"") == std::wstring::npos) {
return value;
}
std::wstring output = L"\"";
std::size_t slashes = 0;
for (const auto character : value) {
if (character == L'\\') {
++slashes;
} else if (character == L'\"') {
output.append(slashes * 2 + 1, L'\\');
output += character;
slashes = 0;
} else {
output.append(slashes, L'\\');
output += character;
slashes = 0;
}
}
output.append(slashes * 2, L'\\');
return output + L"\"";
}
std::wstring commandLine(const ProcessRequest& request) {
std::wstring value = quote(request.program.wstring());
for (const auto& argument : request.arguments) {
value += L" " + quote(widen(argument));
}
return value;
}
class WindowsProcessRunner final : public IProcessRunner {
public:
ProcessResult run(const ProcessRequest& request) const override {
ProcessResult result;
SECURITY_ATTRIBUTES attributes{sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE};
HANDLE readPipe = nullptr;
HANDLE writePipe = nullptr;
if (!CreatePipe(&readPipe, &writePipe, &attributes, 0)) {
result.error = "CreatePipe failed.";
return result;
}
SetHandleInformation(readPipe, HANDLE_FLAG_INHERIT, 0);
STARTUPINFOW startup{};
startup.cb = sizeof(startup);
startup.dwFlags = STARTF_USESTDHANDLES;
startup.hStdOutput = writePipe;
startup.hStdError = writePipe;
startup.hStdInput = GetStdHandle(STD_INPUT_HANDLE);
PROCESS_INFORMATION process{};
auto command = commandLine(request);
const auto directory = request.workingDirectory.wstring();
const BOOL created = CreateProcessW(request.program.wstring().c_str(), command.data(),
nullptr, nullptr, TRUE, CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP, nullptr,
directory.empty() ? nullptr : directory.c_str(), &startup, &process);
CloseHandle(writePipe);
if (!created) {
CloseHandle(readPipe);
result.error = "CreateProcessW failed: " + std::to_string(GetLastError());
return result;
}
result.started = true;
HANDLE job = CreateJobObjectW(nullptr, nullptr);
if (job) {
JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{};
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
SetInformationJobObject(job, JobObjectExtendedLimitInformation, &limits, sizeof(limits));
AssignProcessToJobObject(job, process.hProcess);
}
const DWORD waitTime = static_cast<DWORD>(request.timeout.count());
if (WaitForSingleObject(process.hProcess, waitTime) == WAIT_TIMEOUT) {
result.timedOut = true;
if (job) {
TerminateJobObject(job, 1);
} else {
TerminateProcess(process.hProcess, 1);
}
WaitForSingleObject(process.hProcess, 1000);
}
char buffer[4096];
DWORD count = 0;
while (ReadFile(readPipe, buffer, sizeof(buffer), &count, nullptr) && count > 0) {
result.output.append(buffer, count);
}
DWORD exitCode = 1;
GetExitCodeProcess(process.hProcess, &exitCode);
result.exitCode = static_cast<int>(exitCode);
CloseHandle(readPipe);
if (job) {
CloseHandle(job);
}
CloseHandle(process.hThread);
CloseHandle(process.hProcess);
return result;
}
};
} // namespace
std::unique_ptr<IProcessRunner> createDefaultProcessRunner() {
return std::make_unique<WindowsProcessRunner>();
}
} // namespace dts
+282
View File
@@ -0,0 +1,282 @@
#include "preview/RuntimePlugin.hpp"
#include <cstring>
#include <utility>
#if defined(_WIN32)
#define NOMINMAX
#include <windows.h>
#else
#include <dlfcn.h>
#endif
namespace dts {
namespace {
void* openLibrary(const std::filesystem::path& path, std::string& error) {
#if defined(_WIN32)
auto* handle = LoadLibraryW(path.wstring().c_str());
if (!handle) {
error = "LoadLibraryW failed: " + std::to_string(GetLastError());
}
return handle;
#else
auto* handle = dlopen(path.c_str(), RTLD_NOW | RTLD_LOCAL);
if (!handle) {
error = dlerror();
}
return handle;
#endif
}
void closeLibrary(void* handle) {
#if defined(_WIN32)
FreeLibrary(static_cast<HMODULE>(handle));
#else
dlclose(handle);
#endif
}
void* findSymbol(void* handle, const char* name) {
#if defined(_WIN32)
return reinterpret_cast<void*>(GetProcAddress(static_cast<HMODULE>(handle), name));
#else
return dlsym(handle, name);
#endif
}
template <typename Function>
Function functionPointer(void* symbol) {
Function function = nullptr;
static_assert(sizeof(function) == sizeof(symbol));
std::memcpy(&function, &symbol, sizeof(function));
return function;
}
std::string utf8Path(const std::filesystem::path& path) {
const auto value = path.u8string();
return {reinterpret_cast<const char*>(value.data()), value.size()};
}
} // namespace
RuntimePlugin::~RuntimePlugin() {
unload();
}
RuntimePlugin::RuntimePlugin(RuntimePlugin&& other) noexcept {
*this = std::move(other);
}
RuntimePlugin& RuntimePlugin::operator=(RuntimePlugin&& other) noexcept {
if (this != &other) {
unload();
handle_ = std::exchange(other.handle_, nullptr);
getApiVersion_ = std::exchange(other.getApiVersion_, nullptr);
getInfo_ = std::exchange(other.getInfo_, nullptr);
create_ = std::exchange(other.create_, nullptr);
destroy_ = std::exchange(other.destroy_, nullptr);
load_ = std::exchange(other.load_, nullptr);
getSkeletonInfo_ = std::exchange(other.getSkeletonInfo_, nullptr);
getAnimationName_ = std::exchange(other.getAnimationName_, nullptr);
getAnimationDuration_ = std::exchange(other.getAnimationDuration_, nullptr);
getSkinName_ = std::exchange(other.getSkinName_, nullptr);
getLastError_ = std::exchange(other.getLastError_, nullptr);
setAnimation_ = std::exchange(other.setAnimation_, nullptr);
update_ = std::exchange(other.update_, nullptr);
getDrawCommandCount_ = std::exchange(other.getDrawCommandCount_, nullptr);
getDrawCommand_ = std::exchange(other.getDrawCommand_, nullptr);
context_ = std::exchange(other.context_, nullptr);
}
return *this;
}
bool RuntimePlugin::load(const std::filesystem::path& path, std::string& error) {
unload();
handle_ = openLibrary(path, error);
if (!handle_) {
return false;
}
getApiVersion_ = functionPointer<GetApiVersion>(findSymbol(handle_, "dtsRuntimeGetApiVersion"));
getInfo_ = functionPointer<GetInfo>(findSymbol(handle_, "dtsRuntimeGetInfo"));
if (!getApiVersion_ || !getInfo_) {
error = "Runtime plugin does not expose the required DateToSpine ABI.";
unload();
return false;
}
if (getApiVersion_() != DTS_RUNTIME_API_VERSION) {
error = "Runtime plugin API version is incompatible.";
unload();
return false;
}
create_ = functionPointer<Create>(findSymbol(handle_, "dtsRuntimeCreate"));
destroy_ = functionPointer<Destroy>(findSymbol(handle_, "dtsRuntimeDestroy"));
load_ = functionPointer<Load>(findSymbol(handle_, "dtsRuntimeLoad"));
getSkeletonInfo_ = functionPointer<GetSkeletonInfo>(
findSymbol(handle_, "dtsRuntimeGetSkeletonInfo"));
getAnimationName_ = functionPointer<GetName>(
findSymbol(handle_, "dtsRuntimeGetAnimationName"));
getAnimationDuration_ = functionPointer<GetDuration>(
findSymbol(handle_, "dtsRuntimeGetAnimationDuration"));
getSkinName_ = functionPointer<GetName>(findSymbol(handle_, "dtsRuntimeGetSkinName"));
getLastError_ = functionPointer<GetLastError>(findSymbol(handle_, "dtsRuntimeGetLastError"));
setAnimation_ = functionPointer<SetAnimation>(findSymbol(handle_, "dtsRuntimeSetAnimation"));
update_ = functionPointer<Update>(findSymbol(handle_, "dtsRuntimeUpdate"));
getDrawCommandCount_ = functionPointer<GetDrawCommandCount>(
findSymbol(handle_, "dtsRuntimeGetDrawCommandCount"));
getDrawCommand_ = functionPointer<GetDrawCommand>(
findSymbol(handle_, "dtsRuntimeGetDrawCommand"));
return true;
}
void RuntimePlugin::unload() {
if (context_ && destroy_) {
destroy_(context_);
}
context_ = nullptr;
getApiVersion_ = nullptr;
getInfo_ = nullptr;
create_ = nullptr;
destroy_ = nullptr;
load_ = nullptr;
getSkeletonInfo_ = nullptr;
getAnimationName_ = nullptr;
getAnimationDuration_ = nullptr;
getSkinName_ = nullptr;
getLastError_ = nullptr;
setAnimation_ = nullptr;
update_ = nullptr;
getDrawCommandCount_ = nullptr;
getDrawCommand_ = nullptr;
if (handle_) {
closeLibrary(handle_);
handle_ = nullptr;
}
}
bool RuntimePlugin::isLoaded() const {
return handle_ != nullptr;
}
std::optional<RuntimePluginMetadata> RuntimePlugin::metadata(std::string& error) const {
if (!getInfo_) {
error = "Runtime plugin is not loaded.";
return std::nullopt;
}
DtsRuntimeInfo info{};
info.structSize = sizeof(info);
if (getInfo_(&info) != 0 || !info.spineVersionLine || !info.adapterBuild) {
error = "Runtime plugin returned invalid metadata.";
return std::nullopt;
}
return RuntimePluginMetadata{
info.apiVersion,
info.spineVersionLine,
info.adapterBuild,
info.capabilities
};
}
std::optional<SkeletonMetadata> RuntimePlugin::loadSkeleton(
const std::filesystem::path& skeletonPath,
const std::filesystem::path& atlasPath,
float scale,
std::string& error) {
if (!create_ || !destroy_ || !load_ || !getSkeletonInfo_ || !getAnimationName_
|| !getAnimationDuration_ || !getSkinName_ || !getLastError_) {
error = "Runtime plugin does not provide skeleton metadata capability.";
return std::nullopt;
}
if (context_) {
destroy_(context_);
}
context_ = create_();
if (!context_) {
error = "Runtime plugin could not create a context.";
return std::nullopt;
}
const auto skeleton = utf8Path(skeletonPath);
const auto atlas = utf8Path(atlasPath);
DtsLoadRequest request{sizeof(DtsLoadRequest), skeleton.c_str(), atlas.c_str(), scale};
if (load_(context_, &request) != 0) {
const auto* detail = getLastError_(context_);
error = detail ? detail : "Runtime plugin failed to load skeleton data.";
return std::nullopt;
}
DtsSkeletonInfo info{};
info.structSize = sizeof(info);
if (getSkeletonInfo_(context_, &info) != 0) {
error = "Runtime plugin failed to return skeleton metadata.";
return std::nullopt;
}
SkeletonMetadata result;
result.boneCount = info.boneCount;
result.slotCount = info.slotCount;
result.eventCount = info.eventCount;
result.constraintCount = info.constraintCount;
result.animations.reserve(info.animationCount);
for (std::uint32_t index = 0; index < info.animationCount; ++index) {
const auto* name = getAnimationName_(context_, index);
result.animations.push_back({name ? name : "", getAnimationDuration_(context_, index)});
}
result.skins.reserve(info.skinCount);
for (std::uint32_t index = 0; index < info.skinCount; ++index) {
const auto* name = getSkinName_(context_, index);
result.skins.emplace_back(name ? name : "");
}
return result;
}
bool RuntimePlugin::setAnimation(const std::string& name, bool loop, std::string& error) {
if (!context_ || !setAnimation_) {
error = "Runtime plugin does not provide animation playback.";
return false;
}
if (setAnimation_(context_, name.c_str(), loop ? 1 : 0) != 0) {
const auto* detail = getLastError_ ? getLastError_(context_) : nullptr;
error = detail ? detail : "Animation could not be selected.";
return false;
}
return true;
}
std::optional<std::vector<PreviewDrawCommand>> RuntimePlugin::updatePreview(
float deltaSeconds, std::string& error) {
if (!context_ || !update_ || !getDrawCommandCount_ || !getDrawCommand_) {
error = "Runtime plugin does not provide preview drawing.";
return std::nullopt;
}
if (update_(context_, deltaSeconds) != 0) {
const auto* detail = getLastError_ ? getLastError_(context_) : nullptr;
error = detail ? detail : "Preview frame could not be sampled.";
return std::nullopt;
}
std::vector<PreviewDrawCommand> result;
const auto count = getDrawCommandCount_(context_);
result.reserve(count);
for (std::uint32_t index = 0; index < count; ++index) {
DtsDrawCommand source{};
source.structSize = sizeof(source);
if (getDrawCommand_(context_, index, &source) != 0 || !source.texturePathUtf8) {
error = "Runtime plugin returned an invalid draw command.";
return std::nullopt;
}
PreviewDrawCommand command;
command.texturePath = std::filesystem::path(
std::u8string(reinterpret_cast<const char8_t*>(source.texturePathUtf8)));
command.blendMode = source.blendMode;
command.indices.assign(source.indices, source.indices + source.indexCount);
command.vertices.reserve(source.vertexCount);
for (std::uint32_t vertex = 0; vertex < source.vertexCount; ++vertex) {
const auto& value = source.vertices[vertex];
command.vertices.push_back({value.x, value.y, value.u, value.v, value.color});
}
result.push_back(std::move(command));
}
return result;
}
} // namespace dts
+105
View File
@@ -0,0 +1,105 @@
#pragma once
#include "RuntimeApi.h"
#include <cstdint>
#include <filesystem>
#include <optional>
#include <string>
#include <vector>
namespace dts {
struct RuntimePluginMetadata {
std::uint32_t apiVersion = 0;
std::string spineVersionLine;
std::string adapterBuild;
std::uint32_t capabilities = 0;
};
struct AnimationMetadata {
std::string name;
float duration = 0;
};
struct SkeletonMetadata {
std::uint32_t boneCount = 0;
std::uint32_t slotCount = 0;
std::uint32_t eventCount = 0;
std::uint32_t constraintCount = 0;
std::vector<std::string> skins;
std::vector<AnimationMetadata> animations;
};
struct PreviewVertex {
float x = 0;
float y = 0;
float u = 0;
float v = 0;
std::uint32_t color = 0xffffffff;
};
struct PreviewDrawCommand {
std::vector<PreviewVertex> vertices;
std::vector<std::uint16_t> indices;
std::filesystem::path texturePath;
std::uint32_t blendMode = 0;
};
class RuntimePlugin {
public:
RuntimePlugin() = default;
~RuntimePlugin();
RuntimePlugin(const RuntimePlugin&) = delete;
RuntimePlugin& operator=(const RuntimePlugin&) = delete;
RuntimePlugin(RuntimePlugin&& other) noexcept;
RuntimePlugin& operator=(RuntimePlugin&& other) noexcept;
[[nodiscard]] bool load(const std::filesystem::path& path, std::string& error);
void unload();
[[nodiscard]] bool isLoaded() const;
[[nodiscard]] std::optional<RuntimePluginMetadata> metadata(std::string& error) const;
[[nodiscard]] std::optional<SkeletonMetadata> loadSkeleton(
const std::filesystem::path& skeletonPath,
const std::filesystem::path& atlasPath,
float scale,
std::string& error);
[[nodiscard]] bool setAnimation(const std::string& name, bool loop, std::string& error);
[[nodiscard]] std::optional<std::vector<PreviewDrawCommand>> updatePreview(
float deltaSeconds, std::string& error);
private:
using GetApiVersion = std::uint32_t (*)();
using GetInfo = std::int32_t (*)(DtsRuntimeInfo*);
using Create = DtsRuntimeHandle (*)();
using Destroy = void (*)(DtsRuntimeHandle);
using Load = std::int32_t (*)(DtsRuntimeHandle, const DtsLoadRequest*);
using GetSkeletonInfo = std::int32_t (*)(DtsRuntimeHandle, DtsSkeletonInfo*);
using GetName = const char* (*)(DtsRuntimeHandle, std::uint32_t);
using GetDuration = float (*)(DtsRuntimeHandle, std::uint32_t);
using GetLastError = const char* (*)(DtsRuntimeHandle);
using SetAnimation = std::int32_t (*)(DtsRuntimeHandle, const char*, std::int32_t);
using Update = std::int32_t (*)(DtsRuntimeHandle, float);
using GetDrawCommandCount = std::uint32_t (*)(DtsRuntimeHandle);
using GetDrawCommand = std::int32_t (*)(DtsRuntimeHandle, std::uint32_t, DtsDrawCommand*);
void* handle_ = nullptr;
GetApiVersion getApiVersion_ = nullptr;
GetInfo getInfo_ = nullptr;
Create create_ = nullptr;
Destroy destroy_ = nullptr;
Load load_ = nullptr;
GetSkeletonInfo getSkeletonInfo_ = nullptr;
GetName getAnimationName_ = nullptr;
GetDuration getAnimationDuration_ = nullptr;
GetName getSkinName_ = nullptr;
GetLastError getLastError_ = nullptr;
SetAnimation setAnimation_ = nullptr;
Update update_ = nullptr;
GetDrawCommandCount getDrawCommandCount_ = nullptr;
GetDrawCommand getDrawCommand_ = nullptr;
DtsRuntimeHandle context_ = nullptr;
};
} // namespace dts
@@ -0,0 +1,141 @@
#include "scan/SpineDataVersionDetector.hpp"
#include <cstdint>
#include <fstream>
#include <regex>
namespace dts {
namespace {
constexpr std::size_t maxJsonProbeBytes = 1024 * 1024;
constexpr std::size_t maxBinaryStringBytes = 4096;
std::optional<std::uint32_t> readVarint(std::istream& input) {
std::uint32_t value = 0;
for (int shift = 0; shift < 35; shift += 7) {
const int byte = input.get();
if (byte == std::char_traits<char>::eof()) {
return std::nullopt;
}
value |= static_cast<std::uint32_t>(byte & 0x7f) << shift;
if ((byte & 0x80) == 0) {
return value;
}
}
return std::nullopt;
}
std::optional<std::string> readBinaryString(std::istream& input) {
const auto encodedLength = readVarint(input);
if (!encodedLength || *encodedLength == 0) {
return std::nullopt;
}
const std::size_t length = *encodedLength - 1;
if (length > maxBinaryStringBytes) {
return std::nullopt;
}
std::string value(length, '\0');
if (length > 0 && !input.read(value.data(), static_cast<std::streamsize>(length))) {
return std::nullopt;
}
return value;
}
VersionDetectionResult finish(SkeletonDataFormat format, const SpineVersion& version) {
VersionDetectionResult result;
result.format = format;
result.version = version;
result.status = version.isSupported()
? VersionDetectionStatus::Detected
: VersionDetectionStatus::UnsupportedVersion;
result.message = version.isSupported()
? "Spine data version detected."
: "Spine data version is outside the supported range.";
return result;
}
VersionDetectionResult inspectJson(std::ifstream& input) {
std::string text(maxJsonProbeBytes, '\0');
input.read(text.data(), static_cast<std::streamsize>(text.size()));
text.resize(static_cast<std::size_t>(input.gcount()));
static const std::regex bones(R"("bones"\s*:)");
static const std::regex version(
R"regex("spine"\s*:\s*"(\d+\.\d+(?:\.\d+)?(?:-[^"]+)?)")regex");
std::smatch match;
if (!std::regex_search(text, bones) || !std::regex_search(text, match, version)) {
return {SkeletonDataFormat::Json, VersionDetectionStatus::InvalidData, std::nullopt,
"JSON does not contain the minimum Spine skeleton schema."};
}
const auto parsed = SpineVersion::parse(match[1].str());
if (!parsed) {
return {SkeletonDataFormat::Json, VersionDetectionStatus::InvalidData, std::nullopt,
"Spine JSON version is invalid."};
}
return finish(SkeletonDataFormat::Json, *parsed);
}
VersionDetectionResult inspectBinary(std::ifstream& input) {
const auto hash = readBinaryString(input);
const auto versionText = readBinaryString(input);
if (hash && versionText) {
if (const auto version = SpineVersion::parse(*versionText))
return finish(SkeletonDataFormat::Binary, *version);
}
input.clear();
input.seekg(0);
std::string header(256, '\0');
input.read(header.data(), static_cast<std::streamsize>(header.size()));
header.resize(static_cast<std::size_t>(input.gcount()));
static const std::regex embeddedVersion(
R"regex((3\.8(?:\.\d+)?(?:-[A-Za-z0-9.-]+)?|4\.[0-3](?:\.\d+)?(?:-[A-Za-z0-9.-]+)?))regex");
std::smatch match;
if (std::regex_search(header, match, embeddedVersion)) {
if (const auto version = SpineVersion::parse(match[1].str()))
return finish(SkeletonDataFormat::Binary, *version);
}
return {SkeletonDataFormat::Binary, VersionDetectionStatus::InvalidData, std::nullopt,
"SKEL header does not contain a valid Spine version."};
}
} // namespace
VersionDetectionResult SpineDataVersionDetector::inspect(const std::filesystem::path& path) const {
std::ifstream input(path, std::ios::binary);
if (!input) {
return {SkeletonDataFormat::Unknown, VersionDetectionStatus::FileMissing, std::nullopt,
"Skeleton data file could not be opened."};
}
char first = '\0';
while (input.get(first)) {
if (first != ' ' && first != '\t' && first != '\r' && first != '\n') {
break;
}
}
input.clear();
input.seekg(0);
return first == '{' ? inspectJson(input) : inspectBinary(input);
}
std::string toString(SkeletonDataFormat format) {
switch (format) {
case SkeletonDataFormat::Json: return "json";
case SkeletonDataFormat::Binary: return "skel";
case SkeletonDataFormat::Unknown: return "unknown";
}
return "unknown";
}
std::string toString(VersionDetectionStatus status) {
switch (status) {
case VersionDetectionStatus::Detected: return "detected";
case VersionDetectionStatus::FileMissing: return "file-missing";
case VersionDetectionStatus::InvalidData: return "invalid-data";
case VersionDetectionStatus::UnsupportedVersion: return "unsupported-version";
}
return "unknown";
}
} // namespace dts
@@ -0,0 +1,36 @@
#pragma once
#include "domain/SpineVersion.hpp"
#include <filesystem>
#include <optional>
#include <string>
namespace dts {
enum class SkeletonDataFormat { Json, Binary, Unknown };
enum class VersionDetectionStatus {
Detected,
FileMissing,
InvalidData,
UnsupportedVersion
};
struct VersionDetectionResult {
SkeletonDataFormat format = SkeletonDataFormat::Unknown;
VersionDetectionStatus status = VersionDetectionStatus::InvalidData;
std::optional<SpineVersion> version;
std::string message;
};
class SpineDataVersionDetector {
public:
[[nodiscard]] VersionDetectionResult inspect(const std::filesystem::path& path) const;
};
[[nodiscard]] std::string toString(SkeletonDataFormat format);
[[nodiscard]] std::string toString(VersionDetectionStatus status);
} // namespace dts
+547
View File
@@ -0,0 +1,547 @@
#include "ui/MainWindow.hpp"
#include "atlas/AtlasParser.hpp"
#include "conversion/ConversionJob.hpp"
#include "editor/SpineEditorLocator.hpp"
#include "editor/SpineProbe.hpp"
#include "editor/SpineVersionAvailability.hpp"
#include "platform/ProcessRunner.hpp"
#include "scan/SpineDataVersionDetector.hpp"
#include "ui/SpinePreviewWidget.hpp"
#include <QComboBox>
#include <QCheckBox>
#include <QCoreApplication>
#include <QDir>
#include <QDirIterator>
#include <QDragEnterEvent>
#include <QDropEvent>
#include <QFileDialog>
#include <QFileInfo>
#include <QHBoxLayout>
#include <QLabel>
#include <QMimeData>
#include <QMessageBox>
#include <QPushButton>
#include <QSettings>
#include <QTimer>
#include <QtConcurrent/QtConcurrent>
#include <QUrl>
#include <QVBoxLayout>
#include <filesystem>
namespace dts {
namespace {
std::filesystem::path localPath(const QString& value) {
#if defined(_WIN32)
return std::filesystem::path(value.toStdWString());
#else
return std::filesystem::path(value.toStdString());
#endif
}
QString platformLibraryName(const QString& line) {
#if defined(_WIN32)
return QStringLiteral("dts-runtime-%1.dll").arg(line);
#elif defined(__APPLE__)
return QStringLiteral("libdts-runtime-%1.dylib").arg(line);
#else
return QStringLiteral("libdts-runtime-%1.so").arg(line);
#endif
}
} // namespace
MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
setupUi();
setAcceptDrops(true);
setWindowTitle(tr("DateToSpine"));
resize(960, 680);
}
void MainWindow::openResourcePath(const QString& path) {
addInput(path);
}
void MainWindow::setupUi() {
auto* root = new QWidget;
auto* layout = new QVBoxLayout(root);
layout->setContentsMargins(28, 24, 28, 24);
layout->setSpacing(16);
auto* top = new QHBoxLayout;
auto* title = new QLabel(tr("DateToSpine"));
title->setObjectName(QStringLiteral("title"));
availabilityLabel_ = new QLabel;
availabilityLabel_->setAlignment(Qt::AlignCenter);
availabilityLabel_->setMinimumWidth(190);
availabilityLabel_->setVisible(false);
resourceCombo_ = new QComboBox;
resourceCombo_->setMinimumWidth(260);
resourceCombo_->setVisible(false);
auto* addButton = new QPushButton(tr("选择资源"));
addButton->setObjectName(QStringLiteral("quietButton"));
top->addWidget(title);
top->addStretch();
top->addWidget(availabilityLabel_);
top->addStretch();
top->addWidget(resourceCombo_);
top->addWidget(addButton);
layout->addLayout(top);
previewWidget_ = new SpinePreviewWidget;
previewWidget_->setObjectName(QStringLiteral("preview"));
layout->addWidget(previewWidget_, 1);
auto* controls = new QHBoxLayout;
animationCombo_ = new QComboBox;
animationCombo_->setMinimumWidth(220);
animationCombo_->setEnabled(false);
statusLabel_ = new QLabel;
statusLabel_->setObjectName(QStringLiteral("status"));
unpremultiplyCheck_ = new QCheckBox(tr("去除预乘 Alpha"));
unpremultiplyCheck_->setToolTip(tr("由 Spine 官方纹理解包器转换为普通透明图片"));
convertButton_ = new QPushButton(tr("一键转换为 .spine"));
convertButton_->setObjectName(QStringLiteral("primaryButton"));
convertButton_->setEnabled(false);
controls->addWidget(new QLabel(tr("动画")));
controls->addWidget(animationCombo_);
controls->addWidget(statusLabel_, 1);
controls->addWidget(unpremultiplyCheck_);
controls->addWidget(convertButton_);
layout->addLayout(controls);
setCentralWidget(root);
setStyleSheet(QStringLiteral(R"(
QMainWindow, QWidget { background: #17191e; color: #f3f4f6; font-size: 14px; }
QLabel#title { font-size: 22px; font-weight: 650; }
QLabel#preview { background: #101217; border: 2px dashed #343944; border-radius: 14px; color: #a7adb8; }
QLabel#status { color: #9299a5; padding-left: 8px; }
QComboBox { background: #242832; border: 1px solid #3a404c; border-radius: 8px; padding: 8px 12px; }
QPushButton { border-radius: 8px; padding: 9px 16px; }
QPushButton#quietButton { background: #292d36; border: 1px solid #3d4350; }
QPushButton#primaryButton { background: #6e5eea; border: none; font-weight: 650; padding: 11px 20px; }
QPushButton#primaryButton:disabled { background: #343743; color: #777d89; }
QCheckBox { spacing: 8px; color: #c9ced8; }
QCheckBox::indicator { width: 18px; height: 18px; }
)"));
showEmptyState();
connect(addButton, &QPushButton::clicked, this, &MainWindow::chooseResources);
connect(resourceCombo_, &QComboBox::currentIndexChanged,
this, &MainWindow::resourceChanged);
connect(animationCombo_, &QComboBox::currentTextChanged,
this, &MainWindow::animationChanged);
connect(convertButton_, &QPushButton::clicked, this, &MainWindow::convertCurrent);
previewTimer_ = new QTimer(this);
previewTimer_->setInterval(16);
previewTimer_->setTimerType(Qt::PreciseTimer);
connect(previewTimer_, &QTimer::timeout, this, &MainWindow::advancePreview);
editorProbeWatcher_ = new QFutureWatcher<QPair<int, QString>>(this);
connect(editorProbeWatcher_,
&QFutureWatcher<QPair<int, QString>>::finished,
this, &MainWindow::onEditorProbeFinished);
}
void MainWindow::showEmptyState() {
previewWidget_->resetViewport();
previewWidget_->setMessage(
tr("把 .json / .skel / .skel.bytes、.atlas / .atlas.txt 和图片拖到这里\n\n也可以拖入整个资源文件夹"));
statusLabel_->clear();
}
void MainWindow::chooseResources() {
const auto paths = QFileDialog::getOpenFileNames(this, tr("选择导出资源"), {},
tr("Spine 资源 (*.json *.skel *.skel.bytes *.atlas *.atlas.txt *.png *.jpg *.jpeg *.webp);;所有文件 (*)"));
for (const auto& path : paths) addInput(path);
}
void MainWindow::addInput(const QString& path) {
const QFileInfo info(path);
const auto scanDirectory = [this](const QString& directory, bool recursive, bool warnUnsupported) {
QDirIterator iterator(directory, {"*.json", "*.skel", "*.skel.bytes"}, QDir::Files,
recursive ? QDirIterator::Subdirectories : QDirIterator::NoIteratorFlags);
while (iterator.hasNext()) addSkeleton(iterator.next(), warnUnsupported);
};
// Only the primary skeleton data (.json / .skel / .skel.bytes) carries a
// Spine version and should trigger the "unsupported version" warning.
// Companion files (.atlas / .atlas.txt / images) have no version of their
// own, so they must not cause a version check when scanned for a skeleton.
if (info.isDir()) scanDirectory(path, true, true);
else if (info.suffix().compare("json", Qt::CaseInsensitive) == 0
|| info.suffix().compare("skel", Qt::CaseInsensitive) == 0
|| info.completeSuffix().compare("skel.bytes", Qt::CaseInsensitive) == 0) addSkeleton(path, true);
else scanDirectory(info.absolutePath(), false, false);
scheduleAvailabilityCheck();
}
void MainWindow::addSkeleton(const QString& path, bool warnInvalid) {
const auto result = SpineDataVersionDetector{}.inspect(localPath(path));
if (result.status == VersionDetectionStatus::UnsupportedVersion) {
if (warnInvalid) {
const auto version = result.version
? QString::fromStdString(result.version->toString()) : tr("未知");
pendingVersionWarnings_.append(
tr("%1(版本 %2)").arg(QFileInfo(path).fileName(), version));
// Defer to the event loop so multiple unsupported files in a single
// import are collected and reported with one dialog.
QTimer::singleShot(0, this, &MainWindow::showUnsupportedVersionWarning);
}
return;
}
if (result.status == VersionDetectionStatus::InvalidData
|| result.status == VersionDetectionStatus::FileMissing) {
if (warnInvalid) {
pendingInvalidAssets_.append(QFileInfo(path).fileName());
QTimer::singleShot(0, this, &MainWindow::showInvalidAssetWarning);
}
return;
}
if (result.status != VersionDetectionStatus::Detected || skeletonPaths_.contains(path)) return;
skeletonPaths_.append(path);
resourceCombo_->addItem(QFileInfo(path).completeBaseName(), path);
resourceCombo_->setVisible(true);
resourceCombo_->setCurrentIndex(resourceCombo_->count() - 1);
}
void MainWindow::showUnsupportedVersionWarning() {
if (pendingVersionWarnings_.isEmpty()) return;
// Deduplicate so the same resource reported multiple times (e.g. repeated
// drag-and-drop events) is only listed once.
QStringList unique;
for (const auto& item : pendingVersionWarnings_)
if (!unique.contains(item)) unique.append(item);
pendingVersionWarnings_.clear();
const auto details = unique.join(QLatin1String("\n"));
QMessageBox::warning(this, tr("版本过低"),
tr("以下资源的 Spine 版本不在支持的范围内(支持的版本:3.8、4.0、4.1、4.2、4.3):\n\n%1")
.arg(details));
}
void MainWindow::showInvalidAssetWarning() {
if (pendingInvalidAssets_.isEmpty()) return;
// Deduplicate so the same file reported multiple times is only listed once.
QStringList unique;
for (const auto& item : pendingInvalidAssets_)
if (!unique.contains(item)) unique.append(item);
pendingInvalidAssets_.clear();
const auto details = unique.join(QLatin1String("\n"));
QMessageBox::warning(this, tr("未识别到正确的 Spine 资产"),
tr("以下文件未能识别为有效的 Spine 资产:\n\n%1").arg(details));
}
QString MainWindow::findAtlas(const QString& skeletonPath) const {
const QFileInfo skeleton(skeletonPath);
QDir directory(skeleton.absolutePath());
const QString base = skeleton.completeBaseName();
for (const auto& name : {base + ".atlas", base + ".atlas.txt"}) {
const auto candidate = directory.filePath(name);
if (QFileInfo::exists(candidate)) return candidate;
}
const auto atlases = directory.entryInfoList({"*.atlas", "*.atlas.txt"}, QDir::Files);
if (atlases.size() == 1) return atlases.front().absoluteFilePath();
for (const auto& atlas : atlases) {
if (base.startsWith(atlas.completeBaseName())
|| atlas.completeBaseName().startsWith(base)) return atlas.absoluteFilePath();
}
return {};
}
QString MainWindow::runtimeLibrary(const QString& versionLine) const {
const QDir executableDirectory(QCoreApplication::applicationDirPath());
#if defined(__APPLE__)
return QDir::cleanPath(executableDirectory.filePath(
"../PlugIns/runtimes/" + platformLibraryName(versionLine)));
#else
return executableDirectory.filePath("runtimes/" + platformLibraryName(versionLine));
#endif
}
void MainWindow::resourceChanged(int index) {
if (index >= 0) {
loadCurrentPreview();
scheduleAvailabilityCheck();
}
}
void MainWindow::loadCurrentPreview() {
previewTimer_->stop();
previewWidget_->resetViewport();
animationCombo_->clear();
animationCombo_->setEnabled(false);
convertButton_->setEnabled(false);
const QString skeletonPath = resourceCombo_->currentData().toString();
const QString atlasPath = findAtlas(skeletonPath);
const auto detection = SpineDataVersionDetector{}.inspect(localPath(skeletonPath));
if (!detection.version || atlasPath.isEmpty()) {
previewWidget_->setMessage(tr("没有找到与骨骼数据配套的 .atlas 文件"));
statusLabel_->setText(tr("请把完整导出资源一起拖入"));
return;
}
currentVersion_ = QString::fromStdString(detection.version->toString());
std::string error;
if (!runtime_.load(localPath(runtimeLibrary(
QString::fromStdString(detection.version->line()))), error)) {
previewWidget_->setMessage(tr("暂时无法加载这套资源"));
statusLabel_->setText(QString::fromStdString(error));
return;
}
const auto metadata = runtime_.loadSkeleton(
localPath(skeletonPath), localPath(atlasPath), 1.0F, error);
if (!metadata) {
previewWidget_->setMessage(tr("此版本的预览模块仍在接入中"));
statusLabel_->setText(tr("资源已识别,可以稍后转换"));
return;
}
for (const auto& animation : metadata->animations)
animationCombo_->addItem(QString::fromStdString(animation.name), animation.duration);
animationCombo_->setEnabled(animationCombo_->count() > 0);
statusLabel_->setText(tr("已识别 %1 个动画").arg(metadata->animations.size()));
if (animationCombo_->count() > 0) {
animationChanged(animationCombo_->itemText(0));
} else {
auto frame = runtime_.updatePreview(0.0F, error);
if (frame) previewWidget_->setFrame(std::move(*frame));
else previewWidget_->setMessage(tr("没有可播放的动画"));
}
}
void MainWindow::animationChanged(const QString& name) {
if (name.isEmpty()) return;
std::string error;
if (!runtime_.setAnimation(name.toStdString(), true, error)) {
statusLabel_->setText(QString::fromStdString(error));
return;
}
previewClock_.restart();
advancePreview();
previewTimer_->start();
}
void MainWindow::advancePreview() {
std::string error;
// Use the real elapsed wall-clock time as the frame delta so the animation
// matches the actual playback speed even if the timer interval drifts
// (Windows timers are not exact). Falls back to 16ms on the first frame.
const float delta = previewClock_.isValid() ? previewClock_.restart() / 1000.0F : 0.016F;
auto frame = runtime_.updatePreview(delta, error);
if (!frame) {
previewTimer_->stop();
previewWidget_->setMessage(tr("动画预览暂时不可用"));
statusLabel_->setText(QString::fromStdString(error));
return;
}
previewWidget_->setFrame(std::move(*frame));
}
QString MainWindow::resolveEditor() {
QSettings settings;
SpineEditorLocator locator;
const auto saved = settings.value("spineEditorPath").toString();
if (!saved.isEmpty()) {
if (const auto normalized = locator.normalize(localPath(saved)))
return QString::fromStdString(normalized->string());
}
const auto found = locator.discover();
if (!found.empty()) return QString::fromStdString(found.front().string());
return {};
}
void MainWindow::scheduleAvailabilityCheck() {
if (availabilityCheckScheduled_) return;
availabilityCheckScheduled_ = true;
QTimer::singleShot(0, this, [this] {
availabilityCheckScheduled_ = false;
refreshAvailability();
});
}
void MainWindow::showAvailability(const QString& text, const QString& color) {
availabilityLabel_->setText(QStringLiteral("● ") + text);
availabilityLabel_->setStyleSheet(QStringLiteral(
"QLabel { color: %1; background: #20242c; border: 1px solid #343a46; "
"border-radius: 8px; padding: 7px 12px; font-weight: 600; }").arg(color));
availabilityLabel_->setVisible(true);
}
void MainWindow::refreshAvailability() {
availableVersions_.clear();
currentEditorVersion_.clear();
editorPath_ = resolveEditor();
SpineVersionAvailability availability;
for (const auto& path : skeletonPaths_) {
const auto detection = SpineDataVersionDetector{}.inspect(localPath(path));
if (!detection.version) continue;
const auto line = QString::fromStdString(detection.version->line());
if (const auto installed = availability.newestForLine(detection.version->line()))
availableVersions_.insert(line, QString::fromStdString(installed->toString()));
}
const auto current = SpineDataVersionDetector{}.inspect(
localPath(resourceCombo_->currentData().toString()));
if (!current.version) return;
const auto atlasPath = findAtlas(resourceCombo_->currentData().toString());
if (atlasPath.isEmpty()) {
showAvailability(tr("缺少配套 Atlas"), QStringLiteral("#ef6b73"));
convertButton_->setText(tr("资源不完整"));
convertButton_->setEnabled(false);
return;
}
const auto atlas = AtlasParser{}.parseFile(localPath(atlasPath));
if (!atlas.document) {
showAvailability(tr("Atlas 无法识别"), QStringLiteral("#ef6b73"));
convertButton_->setText(tr("资源不完整"));
convertButton_->setEnabled(false);
return;
}
const QDir atlasDirectory = QFileInfo(atlasPath).dir();
for (const auto& page : atlas.document->pages) {
const auto pagePath = QString::fromStdString(page.imagePath.string());
if (!QFileInfo::exists(atlasDirectory.filePath(pagePath))) {
showAvailability(tr("缺少纹理页 %1").arg(QFileInfo(pagePath).fileName()),
QStringLiteral("#ef6b73"));
convertButton_->setText(tr("资源不完整"));
convertButton_->setEnabled(false);
return;
}
}
const auto line = QString::fromStdString(current.version->line());
if (editorPath_.isEmpty()) {
showAvailability(tr("未找到 Spine Editor"), QStringLiteral("#f2b84b"));
convertButton_->setText(tr("指定 Spine 路径"));
convertButton_->setEnabled(true);
return;
}
currentEditorVersion_ = availableVersions_.value(line);
if (currentEditorVersion_.isEmpty()) {
// Run the version check (cache scan + editor probe) in the background
// (bounded by the probe timeout) so the UI does not block. If no
// matching version is found, the user is warned with a dialog.
showAvailability(tr("正在检查 Editor ..."), QStringLiteral("#f2b84b"));
convertButton_->setText(tr("检查中..."));
convertButton_->setEnabled(false);
startEditorProbe(line);
return;
}
showAvailability(tr("可转换 · Spine %1").arg(currentEditorVersion_),
QStringLiteral("#55d68b"));
convertButton_->setText(tr("一键转换为 .spine"));
convertButton_->setEnabled(true);
}
void MainWindow::startEditorProbe(const QString& line) {
const std::string lineStr = line.toStdString();
const auto editorPath = localPath(editorPath_);
const int generation = ++editorProbeGeneration_;
auto future = QtConcurrent::run([lineStr, editorPath, generation]() -> QPair<int, QString> {
// Scan the Spine update cache first; if no matching release is cached,
// probe the explicitly selected Editor executable (bounded timeout).
SpineVersionAvailability availability;
if (const auto cached = availability.newestForLine(lineStr))
return {generation, QString::fromStdString(cached->toString())};
const auto runner = createDefaultProcessRunner();
const SpineProbe probe(*runner);
const auto result = probe.inspect(editorPath, std::chrono::seconds(3));
if (result.editorVersion && result.editorVersion->line() == lineStr)
return {generation, QString::fromStdString(result.editorVersion->toString())};
return {generation, QString{}};
});
editorProbeWatcher_->setFuture(future);
}
void MainWindow::onEditorProbeFinished() {
if (!editorProbeWatcher_ || !editorProbeWatcher_->isFinished()) return;
const auto outcome = editorProbeWatcher_->result();
if (outcome.first != editorProbeGeneration_) return;
const QString version = outcome.second;
if (version.isEmpty()) {
showAvailability(tr("未找到对应版本"), QStringLiteral("#ef6b73"));
convertButton_->setText(tr("指定 Spine 路径"));
convertButton_->setEnabled(true);
// Warn the user one second after the scan when no matching version is found.
QTimer::singleShot(1000, this, [this] {
QMessageBox::warning(this, tr("未找到对应版本"), tr("未找到对应版本"));
});
} else {
currentEditorVersion_ = version;
showAvailability(tr("可转换 · Spine %1").arg(version), QStringLiteral("#55d68b"));
convertButton_->setText(tr("一键转换为 .spine"));
convertButton_->setEnabled(true);
}
}
bool MainWindow::chooseEditorPath() {
const auto selected = QFileDialog::getOpenFileName(this,
tr("指定 Spine 程序"), {}, tr("所有文件 (*)"));
if (selected.isEmpty()) return false;
SpineEditorLocator locator;
const auto normalized = locator.normalize(localPath(selected));
if (!normalized) {
showAvailability(tr("选择的路径不是 Spine Editor"), QStringLiteral("#ef6b73"));
return false;
}
QSettings{}.setValue("spineEditorPath", selected);
editorPath_ = QString::fromStdString(normalized->string());
refreshAvailability();
return !currentEditorVersion_.isEmpty();
}
void MainWindow::convertCurrent() {
if (editorPath_.isEmpty() || currentEditorVersion_.isEmpty())
if (!chooseEditorPath()) return;
const QString input = resourceCombo_->currentData().toString();
const auto outputParent = QFileDialog::getExistingDirectory(this,
tr("选择输出位置"), QFileInfo(input).absolutePath());
if (outputParent.isEmpty()) return;
const auto atlas = findAtlas(input);
if (atlas.isEmpty()) {
QMessageBox::critical(this, tr("转换失败"), tr("没有找到配套的 .atlas 文件。"));
return;
}
ConversionRequest request{
editorPath_, currentEditorVersion_, input, atlas, outputParent,
QFileInfo(input).completeBaseName(), unpremultiplyCheck_->isChecked()};
conversionJob_ = new ConversionJob(std::move(request), this);
connect(conversionJob_, &ConversionJob::finished,
this, &MainWindow::conversionFinished);
setBusy(true);
conversionJob_->start();
}
void MainWindow::setBusy(bool busy) {
convertButton_->setEnabled(!busy);
convertButton_->setText(busy ? tr("正在转换…") : tr("一键转换为 .spine"));
if (busy) showAvailability(tr("正在使用 Spine %1 转换").arg(currentEditorVersion_),
QStringLiteral("#72a7ff"));
}
void MainWindow::conversionFinished(bool success, const QString& outputDirectory,
const QString& error) {
conversionJob_->deleteLater();
conversionJob_ = nullptr;
setBusy(false);
statusLabel_->setText(success ? tr("转换完成") : tr("转换失败"));
statusLabel_->setToolTip(success ? outputDirectory : error);
refreshAvailability();
if (success) {
QMessageBox::information(this, tr("转换完成"),
tr("完整资源已生成:\n%1").arg(outputDirectory));
} else {
QMessageBox::critical(this, tr("转换失败"),
error.isEmpty() ? tr("转换未完成,Spine Editor 未提供详细原因。") : error);
}
}
void MainWindow::dragEnterEvent(QDragEnterEvent* event) {
if (event->mimeData()->hasUrls()) event->acceptProposedAction();
}
void MainWindow::dropEvent(QDropEvent* event) {
for (const auto& url : event->mimeData()->urls())
if (url.isLocalFile()) addInput(url.toLocalFile());
event->acceptProposedAction();
}
} // namespace dts
+85
View File
@@ -0,0 +1,85 @@
#pragma once
#include "preview/RuntimePlugin.hpp"
#include <QMainWindow>
#include <QHash>
#include <QStringList>
#include <QElapsedTimer>
#include <QFutureWatcher>
#include <QPair>
class QComboBox;
class QCheckBox;
class QLabel;
class QPushButton;
class QTimer;
namespace dts {
class SpinePreviewWidget;
class ConversionJob;
class MainWindow final : public QMainWindow {
Q_OBJECT
public:
explicit MainWindow(QWidget* parent = nullptr);
void openResourcePath(const QString& path);
protected:
void dragEnterEvent(QDragEnterEvent* event) override;
void dropEvent(QDropEvent* event) override;
private slots:
void chooseResources();
void resourceChanged(int index);
void convertCurrent();
void conversionFinished(bool success, const QString& outputDirectory,
const QString& error);
void animationChanged(const QString& name);
void advancePreview();
void onEditorProbeFinished();
private:
void setupUi();
void addInput(const QString& path);
void addSkeleton(const QString& path, bool warnInvalid = true);
void loadCurrentPreview();
QString findAtlas(const QString& skeletonPath) const;
QString runtimeLibrary(const QString& versionLine) const;
QString resolveEditor();
void scheduleAvailabilityCheck();
void refreshAvailability();
bool chooseEditorPath();
void showAvailability(const QString& text, const QString& color);
void showEmptyState();
void setBusy(bool busy);
void startEditorProbe(const QString& line);
void showUnsupportedVersionWarning();
void showInvalidAssetWarning();
QComboBox* resourceCombo_ = nullptr;
QComboBox* animationCombo_ = nullptr;
QCheckBox* unpremultiplyCheck_ = nullptr;
SpinePreviewWidget* previewWidget_ = nullptr;
QLabel* statusLabel_ = nullptr;
QLabel* availabilityLabel_ = nullptr;
QPushButton* convertButton_ = nullptr;
QStringList skeletonPaths_;
QString currentVersion_;
QString currentEditorVersion_;
QString editorPath_;
QHash<QString, QString> availableVersions_;
bool availabilityCheckScheduled_ = false;
RuntimePlugin runtime_;
ConversionJob* conversionJob_ = nullptr;
QTimer* previewTimer_ = nullptr;
QElapsedTimer previewClock_;
QFutureWatcher<QPair<int, QString>>* editorProbeWatcher_ = nullptr;
int editorProbeGeneration_ = 0;
QStringList pendingVersionWarnings_;
QStringList pendingInvalidAssets_;
};
} // namespace dts
+195
View File
@@ -0,0 +1,195 @@
#include "ui/SpinePreviewWidget.hpp"
#include <QApplication>
#include <QColor>
#include <QMouseEvent>
#include <QPainter>
#include <QPainterPath>
#include <QPolygonF>
#include <QTransform>
#include <QWheelEvent>
#include <algorithm>
#include <cmath>
#include <limits>
namespace dts {
namespace {
QString localString(const std::filesystem::path& path) {
#if defined(_WIN32)
return QString::fromStdWString(path.wstring());
#else
const auto text = path.u8string();
return QString::fromUtf8(reinterpret_cast<const char*>(text.data()),
static_cast<qsizetype>(text.size()));
#endif
}
QPainter::CompositionMode compositionMode(std::uint32_t blendMode) {
switch (blendMode) {
case 1: return QPainter::CompositionMode_Plus;
case 2: return QPainter::CompositionMode_Multiply;
case 3: return QPainter::CompositionMode_Screen;
default: return QPainter::CompositionMode_SourceOver;
}
}
} // namespace
SpinePreviewWidget::SpinePreviewWidget(QWidget* parent) : QWidget(parent) {
setMinimumSize(640, 440);
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
setAutoFillBackground(false);
setMouseTracking(true);
setToolTip(tr("滚轮缩放 · 中键重置 · 右键拖动 · 右键单击居中"));
}
void SpinePreviewWidget::setFrame(std::vector<PreviewDrawCommand> commands) {
if (!viewBounds_ && !commands.empty()) {
float left = std::numeric_limits<float>::max();
float right = std::numeric_limits<float>::lowest();
float bottom = std::numeric_limits<float>::max();
float top = std::numeric_limits<float>::lowest();
for (const auto& command : commands) for (const auto& vertex : command.vertices) {
left = std::min(left, vertex.x);
right = std::max(right, vertex.x);
bottom = std::min(bottom, vertex.y);
top = std::max(top, vertex.y);
}
if (right > left && top > bottom)
viewBounds_ = QRectF(left, bottom, right - left, top - bottom);
}
commands_ = std::move(commands);
message_.clear();
update();
}
void SpinePreviewWidget::resetViewport() {
viewBounds_.reset();
zoom_ = 1.0F;
pan_ = {};
}
void SpinePreviewWidget::setMessage(const QString& message) {
commands_.clear();
message_ = message;
update();
}
const QImage& SpinePreviewWidget::texture(const std::filesystem::path& path) {
const auto key = localString(path);
auto found = textures_.find(key);
if (found == textures_.end()) found = textures_.insert(key, QImage(key));
return found.value();
}
void SpinePreviewWidget::paintEvent(QPaintEvent*) {
QPainter painter(this);
painter.setRenderHint(QPainter::SmoothPixmapTransform);
painter.fillRect(rect(), QColor("#101217"));
if (commands_.empty()) {
painter.setPen(QColor("#a7adb8"));
painter.drawText(rect(), Qt::AlignCenter | Qt::TextWordWrap, message_);
return;
}
const QRectF bounds = viewBounds_.value_or(QRectF(0, 0, 1, 1));
const float contentWidth = std::max(1.0, bounds.width());
const float contentHeight = std::max(1.0, bounds.height());
const float baseScale = 0.94F * std::min(width() / contentWidth, height() / contentHeight);
const float scale = baseScale * zoom_;
const QPointF center = bounds.center();
const auto toScreen = [this, center, scale](const PreviewVertex& vertex) {
return QPointF(width() * 0.5 + pan_.x() + (vertex.x - center.x()) * scale,
height() * 0.5 + pan_.y() + (vertex.y - center.y()) * scale);
};
for (const auto& command : commands_) {
const auto& image = texture(command.texturePath);
if (image.isNull()) continue;
painter.setCompositionMode(compositionMode(command.blendMode));
for (std::size_t index = 0; index + 2 < command.indices.size(); index += 3) {
const auto i0 = command.indices[index];
const auto i1 = command.indices[index + 1];
const auto i2 = command.indices[index + 2];
if (i0 >= command.vertices.size() || i1 >= command.vertices.size()
|| i2 >= command.vertices.size()) continue;
const auto& v0 = command.vertices[i0];
const auto& v1 = command.vertices[i1];
const auto& v2 = command.vertices[i2];
const QPointF s0(v0.u * image.width(), v0.v * image.height());
const QPointF s1(v1.u * image.width(), v1.v * image.height());
const QPointF s2(v2.u * image.width(), v2.v * image.height());
const QPointF d0 = toScreen(v0);
const QPointF d1 = toScreen(v1);
const QPointF d2 = toScreen(v2);
QPolygonF source{s0, s1, s2, s1 + s2 - s0};
QPolygonF destination{d0, d1, d2, d1 + d2 - d0};
QTransform transform;
if (!QTransform::quadToQuad(source, destination, transform)) continue;
QPainterPath clip;
clip.addPolygon(QPolygonF{d0, d1, d2});
painter.save();
painter.setClipPath(clip);
painter.setOpacity(QColor::fromRgba(v0.color).alphaF());
painter.setTransform(transform);
painter.drawImage(QPointF(0, 0), image);
painter.restore();
}
}
}
void SpinePreviewWidget::wheelEvent(QWheelEvent* event) {
if (commands_.empty() || event->angleDelta().y() == 0) return;
const float previousZoom = zoom_;
const float factor = event->angleDelta().y() > 0 ? 1.12F : 1.0F / 1.12F;
zoom_ = std::clamp(previousZoom * factor, 0.15F, 8.0F);
const QPointF canvasCenter(width() * 0.5, height() * 0.5);
const QPointF cursorOffset = event->position() - canvasCenter;
pan_ = cursorOffset - (cursorOffset - pan_) * (zoom_ / previousZoom);
update();
event->accept();
}
void SpinePreviewWidget::mousePressEvent(QMouseEvent* event) {
if (event->button() == Qt::MiddleButton) {
zoom_ = 1.0F;
pan_ = {};
update();
event->accept();
return;
}
if (event->button() == Qt::RightButton) {
rightPressPosition_ = event->position();
lastMousePosition_ = event->position();
rightDragging_ = false;
setCursor(Qt::ClosedHandCursor);
event->accept();
}
}
void SpinePreviewWidget::mouseMoveEvent(QMouseEvent* event) {
if (!(event->buttons() & Qt::RightButton)) return;
if (!rightDragging_ && (event->position() - rightPressPosition_).manhattanLength()
>= QApplication::startDragDistance()) rightDragging_ = true;
if (rightDragging_) {
pan_ += event->position() - lastMousePosition_;
update();
}
lastMousePosition_ = event->position();
event->accept();
}
void SpinePreviewWidget::mouseReleaseEvent(QMouseEvent* event) {
if (event->button() != Qt::RightButton) return;
unsetCursor();
if (!rightDragging_) {
pan_ = {};
update();
}
rightDragging_ = false;
event->accept();
}
} // namespace dts
+44
View File
@@ -0,0 +1,44 @@
#pragma once
#include "preview/RuntimePlugin.hpp"
#include <QHash>
#include <QImage>
#include <QPointF>
#include <QRectF>
#include <QWidget>
#include <optional>
namespace dts {
class SpinePreviewWidget final : public QWidget {
public:
explicit SpinePreviewWidget(QWidget* parent = nullptr);
void setFrame(std::vector<PreviewDrawCommand> commands);
void setMessage(const QString& message);
void resetViewport();
protected:
void paintEvent(QPaintEvent* event) override;
void wheelEvent(QWheelEvent* event) override;
void mousePressEvent(QMouseEvent* event) override;
void mouseMoveEvent(QMouseEvent* event) override;
void mouseReleaseEvent(QMouseEvent* event) override;
private:
const QImage& texture(const std::filesystem::path& path);
std::vector<PreviewDrawCommand> commands_;
QHash<QString, QImage> textures_;
QString message_;
std::optional<QRectF> viewBounds_;
float zoom_ = 1.0F;
QPointF pan_;
QPointF rightPressPosition_;
QPointF lastMousePosition_;
bool rightDragging_ = false;
};
} // namespace dts
+350
View File
@@ -0,0 +1,350 @@
#include "atlas/AtlasParser.hpp"
#include "domain/SpineVersion.hpp"
#include "editor/SpineEditorLocator.hpp"
#include "editor/SpineOutputParser.hpp"
#include "editor/SpineVersionAvailability.hpp"
#include "platform/ProcessRunner.hpp"
#include "preview/RuntimePlugin.hpp"
#include "scan/SpineDataVersionDetector.hpp"
#include <chrono>
#include <algorithm>
#include <cmath>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <string>
namespace {
int failures = 0;
void expect(bool condition, const std::string& message) {
if (!condition) {
std::cerr << "FAILED: " << message << '\n';
++failures;
}
}
void testVersions() {
const auto supported = dts::SpineVersion::parse("3.8.99");
expect(supported.has_value(), "3.8.99 parses");
expect(supported && supported->line() == "3.8", "version line is 3.8");
expect(supported && supported->isSupported(), "3.8.99 is supported");
const auto early = dts::SpineVersion::parse("3.8.19");
expect(early && !early->isSupported(), "3.8.19 is outside formal support");
const auto current = dts::SpineVersion::parse("4.3.6");
expect(current && current->isSupported(), "4.3 is supported");
const auto beta = dts::SpineVersion::parse("4.3.75-beta");
expect(beta && beta->toString() == "4.3.75", "version suffix is accepted");
const auto old = dts::SpineVersion::parse("3.7.94");
expect(old && !old->isSupported(), "3.7 is unsupported");
expect(!dts::SpineVersion::parse("four.two"), "invalid version is rejected");
}
void testLauncherOutput() {
const std::string output =
"Spine Launcher 4.3.06 (macOS Apple Silicon)\n"
"Launching: Spine 3.8.99 Professional\n"
"Starting: Spine 3.8.99\n";
dts::ProcessResult process{true, false, 0, output, {}};
const auto result = dts::SpineOutputParser{}.parse("/Applications/Spine", process);
expect(result.status == dts::EditorProbeStatus::Available, "probe is available");
expect(result.launcherVersion && result.launcherVersion->toString() == "4.3.6",
"launcher version is parsed separately");
expect(result.editorVersion && result.editorVersion->toString() == "3.8.99",
"editor version is parsed separately");
expect(result.edition == "Professional", "edition is parsed");
}
void testTimedOutOutput() {
dts::ProcessResult process;
process.started = true;
process.timedOut = true;
process.exitCode = 143;
process.output = "Spine Launcher 4.3.06\nLaunching: Spine 4.2.83 Professional\n";
const auto result = dts::SpineOutputParser{}.parse("Spine", process);
expect(result.status == dts::EditorProbeStatus::ObservedButTimedOut,
"recognized timeout is distinguished from missing Spine");
}
void testMacBundleNormalization() {
#if defined(__APPLE__)
const auto path = dts::SpineEditorLocator{}.normalize("/Applications/Spine.app");
expect(path.has_value(), "macOS .app is normalized");
expect(path && path->filename() == "Spine", "normalized executable is Spine");
#endif
}
void testProcessTimeout() {
#if !defined(_WIN32)
auto runner = dts::createDefaultProcessRunner();
dts::ProcessRequest request;
request.program = "/bin/sleep";
request.arguments = {"5"};
request.timeout = std::chrono::milliseconds(50);
const auto result = runner->run(request);
expect(result.started, "timeout test process starts");
expect(result.timedOut, "long-running process is timed out");
#endif
}
void writeVarint(std::ofstream& output, std::size_t value) {
while (true) {
auto byte = static_cast<unsigned char>(value & 0x7f);
value >>= 7;
if (value != 0) {
byte |= 0x80;
}
output.put(static_cast<char>(byte));
if (value == 0) {
return;
}
}
}
void writeBinaryString(std::ofstream& output, const std::string& value) {
writeVarint(output, value.size() + 1);
output.write(value.data(), static_cast<std::streamsize>(value.size()));
}
void testDataVersionDetection() {
const auto directory = std::filesystem::temp_directory_path() / "dts-core-tests";
std::filesystem::create_directories(directory);
const auto jsonPath = directory / "skeleton.json";
{
std::ofstream output(jsonPath);
output << R"({"skeleton":{"spine":"4.2.17"},"bones":[{"name":"root"}]})";
}
const auto json = dts::SpineDataVersionDetector{}.inspect(jsonPath);
expect(json.status == dts::VersionDetectionStatus::Detected, "Spine JSON is detected");
expect(json.version && json.version->toString() == "4.2.17", "JSON version is parsed");
const auto binaryPath = directory / "skeleton.skel";
{
std::ofstream output(binaryPath, std::ios::binary);
writeBinaryString(output, "fixture-hash");
writeBinaryString(output, "3.8.99");
}
const auto binary = dts::SpineDataVersionDetector{}.inspect(binaryPath);
expect(binary.status == dts::VersionDetectionStatus::Detected, "SKEL is detected");
expect(binary.version && binary.version->toString() == "3.8.99", "SKEL version is parsed");
const auto genericPath = directory / "generic.json";
{
std::ofstream output(genericPath);
output << R"({"spine":"4.2.0","items":[]})";
}
const auto generic = dts::SpineDataVersionDetector{}.inspect(genericPath);
expect(generic.status == dts::VersionDetectionStatus::InvalidData,
"ordinary JSON is not accepted as Spine data");
std::filesystem::remove_all(directory);
}
void testAtlasParsing() {
const std::string atlas =
"hero.webp\n"
"size: 1024, 512\n"
"filter: Linear, Linear\n"
"scale: 0.5\n"
"pma: true\n"
"body/arm\n"
"bounds: 10, 20, 30, 40\n"
"offsets: 1, 2, 32, 44\n"
"rotate: 90\n"
"\n"
"hero-2.jpg\n"
"size: 256, 256\n"
"face\n"
"xy: 4, 5\n"
"size: 20, 30\n"
"orig: 22, 34\n";
const auto result = dts::AtlasParser{}.parseText(atlas);
expect(result.document.has_value(), "multi-page Atlas parses");
expect(result.document && result.document->pages.size() == 2, "two pages are found");
const auto& first = result.document->pages.front();
expect(first.imagePath == "hero.webp", "WebP page path is retained");
expect(first.premultipliedAlpha == true, "PMA is parsed");
expect(first.scale && std::abs(*first.scale - 0.5) < 0.000001,
"Atlas page scale is parsed");
expect(first.regions.size() == 1 && first.regions.front().rotated,
"rotated region is parsed");
const auto& second = result.document->pages.back();
expect(second.regions.size() == 1 && second.regions.front().size.size() == 2,
"legacy region size is parsed");
}
void testInstalledVersionDiscovery() {
const auto directory = std::filesystem::temp_directory_path() / "dts-version-cache-tests";
std::filesystem::create_directories(directory);
for (const auto* name : {"4.3.18", "4.3.23", "4.2.43", "not-a-version"}) {
std::ofstream output(directory / name, std::ios::binary);
output.put('x');
}
dts::SpineVersionAvailability availability;
const auto selected = availability.newestForLine("4.3", {directory});
expect(selected && selected->toString() == "4.3.23",
"newest locally cached patch is selected for a version line");
expect(selected && availability.isCached(*selected, {directory}),
"selected exact editor version is confirmed in local cache");
expect(!availability.newestForLine("4.1", {directory}),
"missing cached version line is not reported as available");
std::filesystem::remove_all(directory);
}
void expectRuntimePlugin(const std::string& path, const std::string& versionLine) {
dts::RuntimePlugin plugin;
std::string error;
expect(plugin.load(path, error), versionLine + " Runtime plugin loads: " + error);
const auto metadata = plugin.metadata(error);
expect(metadata.has_value(), "Runtime metadata is available: " + error);
expect(metadata && metadata->apiVersion == DTS_RUNTIME_API_VERSION,
"Runtime API version matches");
expect(metadata && metadata->spineVersionLine == versionLine,
"Runtime line is " + versionLine);
}
void testRuntimePlugins() {
#if defined(DTS_RUNTIME_38_PATH)
expectRuntimePlugin(DTS_RUNTIME_38_PATH, "3.8");
expectRuntimePlugin(DTS_RUNTIME_40_PATH, "4.0");
expectRuntimePlugin(DTS_RUNTIME_41_PATH, "4.1");
expectRuntimePlugin(DTS_RUNTIME_42_PATH, "4.2");
expectRuntimePlugin(DTS_RUNTIME_43_PATH, "4.3");
#endif
}
void testRuntime43OfficialAssets() {
#if defined(DTS_TEST_SPINEBOY_JSON)
const auto jsonVersion = dts::SpineDataVersionDetector{}.inspect(DTS_TEST_SPINEBOY_JSON);
expect(jsonVersion.status == dts::VersionDetectionStatus::Detected,
"official 4.3 beta JSON version is detected");
const auto binaryVersion = dts::SpineDataVersionDetector{}.inspect(DTS_TEST_SPINEBOY_SKEL);
expect(binaryVersion.status == dts::VersionDetectionStatus::Detected,
"official 4.3 binary header version is detected");
dts::RuntimePlugin plugin;
std::string error;
expect(plugin.load(DTS_RUNTIME_43_PATH, error), "4.3 Runtime loads for official assets");
const auto json = plugin.loadSkeleton(
DTS_TEST_SPINEBOY_JSON, DTS_TEST_SPINEBOY_ATLAS, 1.0F, error);
expect(json.has_value(), "official Spineboy JSON loads: " + error);
expect(json && json->boneCount > 0, "Spineboy JSON contains bones");
expect(json && !json->animations.empty(), "Spineboy JSON contains animations");
if (json && !json->animations.empty()) {
const auto idle = std::find_if(json->animations.begin(), json->animations.end(),
[](const auto& animation) { return animation.name == "idle"; });
const auto& animated = idle == json->animations.end()
? json->animations.front() : *idle;
expect(plugin.setAnimation(animated.name, true, error),
"Spineboy animation can be selected: " + error);
const auto frame = plugin.updatePreview(1.0F / 60.0F, error);
expect(frame.has_value(), "Spineboy preview frame is sampled: " + error);
expect(frame && !frame->empty(), "Spineboy preview contains draw commands");
if (frame && !frame->empty()) {
expect(!frame->front().vertices.empty(), "draw command contains vertices");
expect(!frame->front().indices.empty(), "draw command contains triangles");
expect(std::filesystem::exists(frame->front().texturePath),
"draw command texture exists");
}
const auto laterFrame = plugin.updatePreview(0.25F, error);
bool moved = false;
if (frame && laterFrame && frame->size() == laterFrame->size()) {
for (std::size_t command = 0; command < frame->size() && !moved; ++command) {
const auto& before = (*frame)[command].vertices;
const auto& after = (*laterFrame)[command].vertices;
for (std::size_t vertex = 0; vertex < std::min(before.size(), after.size()); ++vertex) {
if (std::abs(before[vertex].x - after[vertex].x) > 0.001F
|| std::abs(before[vertex].y - after[vertex].y) > 0.001F) {
moved = true;
break;
}
}
}
}
expect(moved, "animation sampling changes vertex positions over time");
}
error.clear();
const auto binary = plugin.loadSkeleton(
DTS_TEST_SPINEBOY_SKEL, DTS_TEST_SPINEBOY_ATLAS, 1.0F, error);
expect(binary.has_value(), "official Spineboy SKEL loads: " + error);
expect(binary && binary->boneCount > 0, "Spineboy SKEL contains bones");
expect(binary && !binary->animations.empty(), "Spineboy SKEL contains animations");
#endif
}
void testRuntime42OfficialAssets() {
#if defined(DTS_TEST_42_JSON)
dts::RuntimePlugin plugin;
std::string error;
expect(plugin.load(DTS_RUNTIME_42_PATH, error), "4.2 Runtime loads for official assets");
const auto json = plugin.loadSkeleton(DTS_TEST_42_JSON, DTS_TEST_42_ATLAS, 1.0F, error);
expect(json.has_value(), "official 4.2 Spineboy JSON loads: " + error);
expect(json && !json->animations.empty(), "official 4.2 JSON contains animations");
if (json && !json->animations.empty()) {
expect(plugin.setAnimation(json->animations.front().name, true, error),
"4.2 animation can be selected: " + error);
const auto frame = plugin.updatePreview(1.0F / 60.0F, error);
expect(frame && !frame->empty(), "4.2 preview produces draw commands: " + error);
}
error.clear();
const auto binary = plugin.loadSkeleton(DTS_TEST_42_SKEL, DTS_TEST_42_ATLAS, 1.0F, error);
expect(binary.has_value(), "official 4.2 Spineboy SKEL loads: " + error);
#endif
}
void testLegacyOfficialAsset(const char* line, const char* runtimePath,
const char* jsonPath, const char* binaryPath, const char* atlasPath) {
dts::RuntimePlugin plugin;
std::string error;
expect(plugin.load(runtimePath, error), std::string(line) + " Runtime loads for assets");
const auto json = plugin.loadSkeleton(jsonPath, atlasPath, 1.0F, error);
expect(json.has_value(), std::string("official ") + line + " JSON loads: " + error);
expect(json && !json->animations.empty(), std::string(line) + " JSON contains animations");
if (json && !json->animations.empty()) {
expect(plugin.setAnimation(json->animations.front().name, true, error),
std::string(line) + " animation can be selected: " + error);
const auto first = plugin.updatePreview(1.0F / 60.0F, error);
expect(first && !first->empty(), std::string(line) + " preview draws: " + error);
const auto later = plugin.updatePreview(0.25F, error);
expect(later && !later->empty(), std::string(line) + " later frame draws: " + error);
}
error.clear();
const auto binary = plugin.loadSkeleton(binaryPath, atlasPath, 1.0F, error);
expect(binary.has_value(), std::string("official ") + line + " SKEL loads: " + error);
}
void testLegacyOfficialAssets() {
#if defined(DTS_TEST_38_JSON)
testLegacyOfficialAsset("3.8", DTS_RUNTIME_38_PATH,
DTS_TEST_38_JSON, DTS_TEST_38_SKEL, DTS_TEST_38_ATLAS);
testLegacyOfficialAsset("4.0", DTS_RUNTIME_40_PATH,
DTS_TEST_40_JSON, DTS_TEST_40_SKEL, DTS_TEST_40_ATLAS);
testLegacyOfficialAsset("4.1", DTS_RUNTIME_41_PATH,
DTS_TEST_41_JSON, DTS_TEST_41_SKEL, DTS_TEST_41_ATLAS);
#endif
}
} // namespace
int main() {
testVersions();
testLauncherOutput();
testTimedOutOutput();
testMacBundleNormalization();
testProcessTimeout();
testDataVersionDetection();
testAtlasParsing();
testInstalledVersionDiscovery();
testRuntimePlugins();
testLegacyOfficialAssets();
testRuntime42OfficialAssets();
testRuntime43OfficialAssets();
if (failures == 0) {
std::cout << "All core tests passed.\n";
}
return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
}
+19
View File
@@ -0,0 +1,19 @@
CMakeLists.txt
examples
spine-as3
spine-c
spine-cocos2d-objc
spine-cocos2dx
spine-corona
spine-csharp
spine-js
spine-libgdx
spine-love
spine-lua
spine-monogame
spine-sfml
spine-starling
spine-threejs
spine-tk2d
spine-unity
spine-xna
+922
View File
@@ -0,0 +1,922 @@
# 3.8
## AS3
* **Breaking changes**
* Renamed `Slot#getAttachmentVertices()` to `Slot#getDeform()`.
* Changed the `.json` curve format and added more assumptions for omitted values, reducing the average size of JSON exports.
* Renamed `Skin#addAttachment()` to `Skin#setAttachment()`.
* Removed `VertexAttachment#applyDeform()` and replaced it with `VertexAttachment#deformAttachment`. The attachment set on this field is used to decide if a `DeformTimeline` should be applied to the attachment active on the slot to which the timeline is applied.
* Removed `inheritDeform` field, getter, and setter from `MeshAttachment`.
* Changed `.skel` binary format, added a string table. References to strings in the data resolve to this string table, reducing storage size of binary files considerably.
* Changed the `.json` and `.skel` file formats to accomodate the new feature and file size optimiations. Old projects must be exported with Spine 3.8.20+ to be compatible with the 3.8 Spine runtimes.
* Switched projects from FDT to Visual Studio Code. See updated `README.md` files for instructions.
* **Additions**
* Added `SkeletonBinary` to load binary `.skel` files. See `MixAndMatchExample.as` in `spine-startling-example`.
* Added `x` and `y` coordinates for setup pose AABB in `SkeletonData`.
* Added support for rotated mesh region UVs.
* Added skin-specific bones and constraints which are only updated if the skeleton's current skin contains them.
* Improved Skin API to make it easier to handle mix-and-match use cases.
* Added `Skin#getAttachments()`. Returns all attachments in the skin.
* Added `Skin#getAttachments(int slotIndex)`. Returns all attachements in the skin for the given slot index.
* Added `Skin#addSkin(Skin skin)`. Adds all attachments, bones, and skins from the specified skin to this skin.
* Added `Skin#copySkin(Skin skin)`. Adds all attachments, bones, and skins from the specified skin to this skin. `VertexAttachment` are shallowly copied and will retain any parent mesh relationship. All other attachment types are deep copied.
* Added `Attachment#copy()` to all attachment type implementations. This lets you deep copy an attachment to modify it independently from the original, i.e. when programmatically changing texture coordinates or mesh vertices.
* Added `MeshAttachment#newLinkedMesh()`, creates a linked mesh linkted to either the original mesh, or the parent of the original mesh.
* Added IK softness.
### Starling
* Added `MixAndMatchExample.as` to demonstrate the new Skin API additions and how to load binary `.skel` files.
* Switched projects from FDT to Visual Studio Code. See updated `README.md` files for instructions.
## C
* **Breaking changes**
* Renamed `spSlot#attachmentVertices` to `spSlot#deform`.
* Changed the `.json` curve format and added more assumptions for omitted values, reducing the average size of JSON exports.
* Renamed `spSkin_addAttachment()` to `Skin#spSkin_addAttachment()`.
* Removed `spVertexAttachment_applyDeform()` and replaced it with `VertexAttachment#deformAttachment`. The attachment set on this field is used to decide if a `spDeformTimeline` should be applied to the attachment active on the slot to which the timeline is applied.
* Removed `inheritDeform` field, getter, and setter from `spMeshAttachment`.
* Changed `.skel` binary format, added a string table. References to strings in the data resolve to this string table, reducing storage size of binary files considerably.
* Changed the `.json` and `.skel` file formats to accomodate the new feature and file size optimiations. Old projects must be exported with Spine 3.8.20+ to be compatible with the 3.8 Spine runtimes.
* **Additions**
* Added `x` and `y` coordinates for setup pose AABB in `spSkeletonData`.
* Added support for rotated mesh region UVs.
* Added skin-specific bones and constraints which are only updated if the skeleton's current skin contains them.
* Improved Skin API to make it easier to handle mix-and-match use cases.
* Added `spSkin_getAttachments()`. Returns all attachments in the skin.
* Added `spSkin_getAttachments(int slotIndex)`. Returns all attachements in the skin for the given slot index.
* Added `spSkin_addSkin(spSkin* skin)`. Adds all attachments, bones, and skins from the specified skin to this skin.
* Added `spSkin_copySkin(spSkin* skin)`. Adds all attachments, bones, and skins from the specified skin to this skin. `spVertexAttachment` are shallowly copied and will retain any parent mesh relationship. All other attachment types are deep copied.
* All attachments inserted into skins are reference counted. When the last skin referencing an attachment is disposed, the attachment will also be disposed.
* Added `spAttachment_copy()` to all attachment type implementations. This lets you deep copy an attachment to modify it independently from the original, i.e. when programmatically changing texture coordinates or mesh vertices.
* Added `spMeshAttachment_newLinkedMesh()`, creates a linked mesh linkted to either the original mesh, or the parent of the original mesh.
* Added IK softness.
### Cocos2d-Objc
* Added mix-and-match example to demonstrate the new Skin API.
* Added `IKExample`.
* Added `SkeletonAnimation preUpdateWorldTransformsListener` and `SkeletonAnimation postUpdateWorldTransformsListener`. When set, these callbacks will be invokved before and after the skeleton's `updateWorldTransforms()` method is called. See the `IKExample` how it can be used.
### SFML
* Added mix-and-match example to demonstrate the new Skin API.
* Added `IKExample`.
## C++
* **Breaking Changes**
* Renamed `Slot::getAttachmentVertices()` to `Slot::getDeform()`.
* Changed the `.json` curve format and added more assumptions for omitted values, reducing the average size of JSON exports.
* Renamed `Skin::addAttachment()` to `Skin::setAttachment()`.
* Removed `VertexAttachment::applyDeform()` and replaced it with `VertexAttachment::getDeformAttachment()`. The attachment set on this field is used to decide if a `DeformTimeline` should be applied to the attachment active on the slot to which the timeline is applied.
* Removed `_inheritDeform` field, getter, and setter from `MeshAttachment`.
* Changed `.skel` binary format, added a string table. References to strings in the data resolve to this string table, reducing storage size of binary files considerably.
* Changed the `.json` and `.skel` file formats to accomodate the new feature and file size optimiations. Old projects must be exported with Spine 3.8.20+ to be compatible with the 3.8 Spine runtimes.
* **Additions**
* `AnimationState` and `TrackEntry` now also accept a subclass of `AnimationStateListenerObject` as a listener for animation events in the overloaded `setListener()` method.
* `SkeletonBinary` and `SkeletonJson` now parse and set all non-essential data like audio path.
* Added `x` and `y` coordinates for setup pose AABB in `SkeletonData`.
* Added support for rotated mesh region UVs.
* Added skin-specific bones and constraints which are only updated if the skeleton's current skin contains them.
* Improved Skin API to make it easier to handle mix-and-match use cases.
* Added `Skin#getAttachments()`. Returns all attachments in the skin.
* Added `Skin#getAttachments(int slotIndex)`. Returns all attachements in the skin for the given slot index.
* Added `Skin#addSkin(Skin &skin)`. Adds all attachments, bones, and skins from the specified skin to this skin.
* Added `Skin#copySkin(Skin &skin)`. Adds all attachments, bones, and skins from the specified skin to this skin. `VertexAttachment` are shallowly copied and will retain any parent mesh relationship. All other attachment types are deep copied.
* All attachments inserted into skins are reference counted. When the last skin referencing an attachment is disposed, the attachment will also be disposed.
* Added `Attachment#copy()` to all attachment type implementations. This lets you deep copy an attachment to modify it independently from the original, i.e. when programmatically changing texture coordinates or mesh vertices.
* Added `MeshAttachment#newLinkedMesh()`, creates a linked mesh linkted to either the original mesh, or the parent of the original mesh.
* Added IK softness.
* Exposed `x` and `y` on `SkeletonData` through getters and setters.
* **Bug fixes**
* Fixed binary skeleton loading to stop after default skin attachment load errors, preventing corrupted reads when atlas regions are missing.
### Cocos2d-x
* Updated to cocos2d-x 3.17.1
* Added mix-and-match example to demonstrate the new Skin API.
* Exmaple project requires Visual Studio 2019 on Windows
* Added `IKExample`.
* Added `SkeletonAnimation::setPreUpdateWorldTransformsListener()` and `SkeletonAnimation::setPreUpdateWorldTransformsListener()`. When set, these callbacks will be invokved before and after the skeleton's `updateWorldTransforms()` method is called. See the `IKExample` how it can be used.
### SFML
* Added mix-and-match example to demonstrate the new Skin API.
### UE4
* Added `bAutoPlaying` flag to `USpineSkeletonAnimationComponent`. When `false`, the component will not update the internal animation state and skeleton.
* Updated example project to UE 4.22.
* (Re-)Importing Spine assets will perform a version compatibility check and alert users about mismatches in editor mode.
* `USpineSkeletonRendererComponent` allows passing a `USpineSkeletonComponent` to update it. This way, the renderer component can be used without a skeleton component on the same actor.
* Added blueprint-callable methods to `SpineSkeletonComponent` and `SpineSkeletonAnimationComponent` to query and set skins, and enumerate bones, slots, and animations.
* Extended skeleton data editor preview. The preview now shows bones, slots, animations, and skins found in the skeleton data. See this [blog post](http://esotericsoftware.com/blog/Unreal-Engine-4-quality-of-life-improvements).
* Added preview animation and skin fields, allowing you to preview animations and skins right in the editor. See this [blog post](http://esotericsoftware.com/blog/Unreal-Engine-4-quality-of-life-improvements).
* Removed dependency on `RHI`, `RenderCore`, and `ShaderCore`.
* Re-importing atlases and their textures now works consistently in all situations.
* Added mix-and-match example to demonstrate the new Skin API.
* Materials on `SkeletonRendererComponent` are now blueprint read and writeable. This allows setting dynamic material instances at runtime.
* Added `InitialSkin` property to `USpineWidget`. This allows previewing different skins in the UMG Designer. Initial skins can still be overridden via blueprint events such as `On Initialized`.
## C# ##
* **Breaking changes**
* **Changed `IkConstraintData.Bones` type from `List<BoneData>` to `ExposedList<BoneData>`** for unification reasons. *Note: this modification will most likely not affect user code.*
* Renamed `Slot.AttachmentVertices` to `Slot.Deform`.
* Changed the `.json` curve format and added more assumptions for omitted values, reducing the average size of JSON exports.
* Renamed `Skin.AddAttachment()` to `Skin.SetAttachment()`.
* Removed `FindAttachmentsForSlot(int slotIndex, List<Attachment> attachments)` and `FindNamesForSlot (int slotIndex, List<string> names)` and replaced it with `Skin.GetAttachments(int slotIndex, List<SkinEntry> attachments)` which returns the combined `SkinEntry` object holding both name and attachment.
* Removed `VertexAttachment.ApplyDeform()` and replaced it with `VertexAttachment.DeformAttachment`. The attachment set on this field is used to decide if a `DeformTimeline` should be applied to the attachment active on the slot to which the timeline is applied.
* Removed `inheritDeform` field, getter, and setter from `MeshAttachment`.
* Changed `.skel` binary format, added a string table. References to strings in the data resolve to this string table, reducing storage size of binary files considerably.
* Changed the `.json` and `.skel` file formats to accomodate the new feature and file size optimiations. Old projects must be exported with Spine 3.8.20+ to be compatible with the 3.8 Spine runtimes.
* **Additions**
* Added `x` and `y` coordinates for setup pose AABB in `SkeletonData`.
* Added support for rotated mesh region UVs.
* Added skin-specific bones and constraints which are only updated if the skeleton's current skin contains them.
* Improved Skin API to make it easier to handle mix-and-match use cases.
* Added `Skin.GetAttachments()`. Returns all attachments in the skin.
* Added `Skin.GetAttachments(int slotIndex, List<SkinEntry> attachments)`. Returns all attachements in the skin for the given slot index. This method replaces `FindAttachmentsForSlot` and `FindNamesForSlot`.
* Added `Skin.AddSkin(Skin skin)`. Adds all attachments, bones, and skins from the specified skin to this skin.
* Added `Skin.CopySkin(Skin skin)`. Adds all attachments, bones, and skins from the specified skin to this skin. `VertexAttachment` are shallowly copied and will retain any parent mesh relationship. All other attachment types are deep copied.
* Added `Attachment.Copy()` to all attachment type implementations. This lets you deep copy an attachment to modify it independently from the original, i.e. when programmatically changing texture coordinates or mesh vertices.
* Added `MeshAttachment.NewLinkedMesh()`, creates a linked mesh linkted to either the original mesh, or the parent of the original mesh.
* Added IK softness.
### Unity
* **Breaking changes**
* **Officially supported Unity versions are 2017.1-2020.2**.
* **Spine `.asmdef` files are again active by default**. They have previously been deactivated to `.txt` extension which is now no longer necessary.
* **Removed PoseSkeleton() and PoseWithAnimation()** extension methods to prevent issues where animations are not mixed out. Problem was that these methods did not set AnimationState, leaving incorrect state at e.g. attachments enabled at slots when starting subsequent animations. As a replacement you can use `AnimationState.ClearTrack(0);` followed by `var entry = AnimationState.SetAnimation(0, animation, loop); entry.TrackTime = time` to achieve similar behaviour.
* **The `Shadow alpha cutoff` shader parameter is now respecting slot-color alpha** values at all Spine shaders. A fragment's texture color alpha is multiplied with slot-color alpha before the result is tested against the `Shadow alpha cutoff` threshold.
* **Removed redundant `Attachment.GetClone()` and `MeshAttachment.GetLinkedClone()` extension methods**. Use methods `Attachment.Copy` and `MeshAttachment.NewLinkedMesh()` instead.
* **Renamed extension method `Attachment.GetClone(bool cloneMeshesAsLinked)` to `Attachment.GetCopy(bool cloneMeshesAsLinked)`** to follow the naming scheme of the Spine API.
* `SkeletonDataAsset.atlasAssets` is now an array of the base class `AtlasAssetBase` instead of `SpineAtlasAsset`, which provides `IEnumerable<> Materials` instead of `List<> materials`. Replace any access via `atlasAsset.materials[0]` with `atlasAsset.Materials.First()` and add a `using System.Linq;` statement.
* **Changed `MeshAttachment.GetLinkedMesh()` method signatures:** removed optional parameters `bool inheritDeform = true, bool copyOriginalProperties = false`.
* Changed namespace `Spine.Unity.Modules` to `Spine.Unity` and `Spine.Unity.Examples` after restructuring (see section below) in respective classes:
* When receiving namespace related errors, replace using statements of `using Spine.Unity.Modules.AttachmentTools;` with `using Spine.Unity.AttachmentTools;`. You can remove `using Spine.Unity.Modules;` statements when a `using Spine.Unity` statement is already present in the file.
* `AttachmentTools`, `SkeletonPartsRenderer`, `SkeletonRenderSeparator`, `SkeletonRendererCustomMaterials` changed to namespace `Spine.Unity`.
* `SkeletonGhost`, `SkeletonGhostRenderer`, `AtlasRegionAttacher`, `SkeletonGraphicMirror`, `SkeletonRagdoll`, `SkeletonRagdoll2D`, `SkeletonUtilityEyeConstraint`, `SkeletonUtilityGroundConstraint`, `SkeletonUtilityKinematicShadow` changed to namespace `Spine.Unity.Examples`.
* Split `Editor/Utility/SpineEditorUtilities` class into multiple files with partial class qualifier.
* Nested classes `SpineEditorUtilities.AssetUtility` and `SpineEditorUtilities.EditorInstantiation` are now no longer nested. If you receive namespace related errors, replace any occurrance of
* `SpineEditorUtilities.AssetUtility` with `AssetUtility` and
* `SpineEditorUtilities.EditorInstantiation` with `EditorInstantiation`.
* **Timeline Support has been moved to a separate UPM Package** Previously the Spine Timeline integration was located in the `Modules/Timeline` directory and was deactivated by default, making it necessary to activate it via the Spine Preferences. Now the Timeline integration has been moved to an additional UPM package which can be found under `Modules/com.esotericsoftware.spine.timeline`.
* **Installation:** You can download the Unity Package Manager (UPM) package via the [download page](http://esotericsoftware.com/spine-unity-download) or find it in the [spine-runtimes/spine-unity/Modules](https://github.com/EsotericSoftware/spine-runtimes/tree/3.8-beta/spine-unity/Modules) subdirectory on the git repository. You can then either unzip (copy if using git) the package to
a) the `Packages` directory in your project where it will automatically be loaded, or
b) to an arbitrary directory outside the Assets directory and then open Package Manager in Unity, select the `+` icon, choose `Add package from disk..` and point it to the package.json file.
The Project panel should now show an entry `Spine Timeline Extensions` under `Packages`. If the directory is not yet listed, you will need to close and re-open Unity to have it display the directory and its contents.
* `SkeletonMecanim`'s `Layer Mix Mode` enum name `MixMode.SpineStyle` has been renamed to `MixMode.Hard`. This is most likely not set via code and thus unlikely to be a problem. Serialized scenes and prefabs are unaffected.
* `SkeletonRootMotion` and `SkeletonMecanimRootMotion` components now support arbitrary bones in the hierarchy as `Root Motion Bone`. Previously there were problems when selecting a non-root bone as `Root Motion Bone`. `Skeleton.ScaleX` and `.ScaleY` and parent bone scale is now respected as well.
* **Additions**
* **Spine Preferences stored in Assets/Editor/SpineSettings.asset** Now Spine uses the new `SettingsProvider` API, storing settings in a SpineSettings.asset file which can be shared with team members. Your old preferences are automatically migrated to the new system.
* Added support for Unity's SpriteMask to `SkeletonAnimation` and `SkeletonMecanim`. All mask interaction modes are supported. See this [blog post](http://esotericsoftware.com/blog/Unity-SpriteMask-and-RectMask2D-support).
* Added support for Unity's RectMask2D to SkeletonGraphics. See this [blog post](http://esotericsoftware.com/blog/Unity-SpriteMask-and-RectMask2D-support).
* Added `Create 2D Hinge Chain` button at `SkeletonUtilityBone` inspector, previously only `Create 3D Hinge Chain` was available.
* **Now supporting Lightweight Render Pipeline (LWRP) through an additional UPM package.**
* **Installation:** You can download the Unity Package Manager (UPM) package via the [download page](http://esotericsoftware.com/spine-unity-download) or find it in the [spine-runtimes/spine-unity/Modules](https://github.com/EsotericSoftware/spine-runtimes/tree/3.8-beta/spine-unity/Modules) subdirectory on the git repository. You can then either unzip (copy if using git) the package to
* a) the `Packages` directory in your project where it will automatically be loaded, or
* b) to an arbitrary directory outside the Assets directory and then open Package Manager in Unity, select the `+` icon, choose `Add package from disk..` and point it to the package.json file.
> If you are using git and Unity 2019.2 or newer versions and receive an error that dependencies could not be resolved by the package manager (only higher versions of Unity's `Lightweight RP` package are available, e.g. `6.9.0` and up), please copy the prepared package-UNITYVERSION.json file for your Unity version (e.g. `package-2019.2.json`) over the existing package.json file to change the dependency accordingly. Unfortunately Unity's Package Manager does not provide a way to specify a version range for a dependency like "5.7.2 - 6.9.0" yet, so this manual step is necessary for git users.
The Project panel should now show an entry `Spine Lightweight RP Shaders` under `Packages`. If the directory is not yet listed, you will need to close and re-open Unity to have it display the directory and its contents.
* **Usage:** The package provides two shaders specifically built for the lightweight render pipeline:
* `Lightweight Render Pipeline/Spine/Skeleton`, as a lightweight variant of the `Spine/Skeleton` shader,
* `Lightweight Render Pipeline/Spine/Skeleton Lit`, as a lightweight variant of the `Spine/Skeleton Lit` shader and
* `Lightweight Render Pipeline/Spine/Sprite`, as a lightweight variant of the `Spine/Sprite/Vertex Lit` and `Pixel Lit` shaders, which were not functioning in the lightweight render pipeline. The shaders can be assigned to materials as usual and will respect your settings of the assigned `LightweightRenderPipelineAsset` under `Project Settings - Graphics`.
* **Restrictions** As all Spine shaders, the LWRP shaders **do not support `Premultiply alpha` (PMA) atlas textures in Linear color space**. Please export your atlas textures as `straight alpha` textures with disabled `Premultiply alpha` setting when using Linear color space. You can check the current color space via `Project Settings - Player - Other Settings - Color Space.`.
* **Example:** You can find an example scene in the package under `com.esotericsoftware.spine.lwrp-shaders-3.8/Examples/LWRP Shaders.unity` that demonstrates usage of the LWRP shaders.
* Added `Spine/Skeleton Lit ZWrite` shader. This variant of the `Spine/Skeleton Lit` shader writes to the depth buffer with configurable depth alpha threshold. Apart from that it is identical to `Spine/Skeleton Lit`.
* Additional yield instructions to wait for animation track events `End`, `Complete` and `Interrupt`.
* `WaitForSpineAnimationComplete` now proves an additional `bool includeEndEvent` parameter, defaults to `false` (previous behaviour).
* Added a new `WaitForSpineAnimationEnd` yield instruction.
* Added a new generic `WaitForSpineAnimation` yield instruction which can be configured to wait for any combination of animation track events. It is now used as base class for `WaitForSpineAnimationComplete` and `WaitForSpineAnimationEnd`.
* Additional **Fix Draw Order** parameter at SkeletonRenderer, defaults to `disabled` (previous behaviour).
Applies only when 3+ submeshes are used (2+ materials with alternating order, e.g. "A B A").
If true, MaterialPropertyBlocks are assigned at each material to prevent aggressive batching of submeshes
by e.g. the LWRP renderer, leading to incorrect draw order (e.g. "A1 B A2" changed to "A1A2 B").
You can leave this parameter disabled when everything is drawn correctly to save the additional performance cost.
* **Additional Timeline features.** SpineAnimationStateClip now provides a `Speed Multiplier`, a start time offset parameter `Clip In`, support for blending successive animations by overlapping tracks. An additional `Use Blend Duration` parameter *(defaults to true)* allows for automatic synchronisation of MixDuration with the current overlap blend duration. An additional Spine preferences parameter `Use Blend Duration` has been added which can be disabled to default to the previous behaviour before this update.
* Additional `SpriteMask and RectMask2D` example scene added for demonstration of mask setup and interaction.
* `Real physics hinge chains` for both 2D and 3D physics. The [SkeletonUtilityBone](http://esotericsoftware.com/spine-unity#SkeletonUtilityBone) Inspector provides an interface to create 2D and 3D hinge chains. Previously created chains have only been respecting gravity, but not momentum of the skeleton or parent bones. The new physics rig created when pressing `Create 3D Hinge Chain` and `Create 2D Hinge Chain` creates a more complex setup that also works when flipping the skeleton. Note that the chain root node is no longer parented to bones of the skeleton. This is a requirement in Unity to have momentum applied properly - do not reparent the chain root to bones of your skeleton, or you will loose any momentum applied by the skeleton's movement.
* `Outline rendering functionality for all shaders.` Every shader now provides an additional set of `Outline` parameters to enable custom outline rendering. When outline rendering is enabled via the `Material` inspector, it automatically switches the shader to the respective `Spine/Outline` shader variant. Outlines are generated by sampling neighbour pixels, so be sure to add enough transparent padding when exporting your atlas textures to fit the desired outline width. In order to enable outline rendering at a skeleton, it is recommended to first prepare an additional outline material copy and then switch the material of the target skeleton to this material. This prevents unnecessary additional runtime material copies and drawcalls. Material switching can be prepared via a [SkeletonRendererCustomMaterials](http://esotericsoftware.com/spine-unity#SkeletonRendererCustomMaterials) component and then enabled or disabled at runtime. Alternatively, you can also directly modify the `SkeletonRenderer.CustomMaterialOverride` property.
Outline rendering is fully supported on `SkeletonGraphic` shaders as well.
* Added `SkeletonRenderer.EditorSkipSkinSync` scripting API property to be able to set custom skins in editor scripts. Enable this property when overwriting the Skeleton's skin from an editor script. Without setting this parameter, changes will be overwritten by the next inspector update. Only affects Inspector synchronisation of skin with `initialSkinName`, not startup initialization.
* All `Spine/SkeletonGraphic` shaders now provide a parameter `CanvasGroup Compatible` which can be enabled to support `CanvasGroup` alpha blending. For correct results, you should then disable `Pma Vertex Colors` in the `SkeletonGraphic` Inspector, in section `Advanced` (otherwise Slot alpha will be applied twice).
* **Now supporting Universal Render Pipeline (URP), including the 2D Renderer pipeline, through an additional UPM package.**
* **Installation:** You can download the Unity Package Manager (UPM) package via the [download page](http://esotericsoftware.com/spine-unity-download) or find it in the [spine-runtimes/spine-unity/Modules](https://github.com/EsotericSoftware/spine-runtimes/tree/3.8-beta/spine-unity/Modules) subdirectory on the git repository. You can then either unzip (copy if using git) the package to
* a) the `Packages` directory in your project where it will automatically be loaded, or
* b) to an arbitrary directory outside the Assets directory and then open Package Manager in Unity, select the `+` icon, choose `Add package from disk..` and point it to the package.json file.
The Project panel should now show an entry `Spine Universal RP Shaders` under `Packages`. If the directory is not yet listed, you will need to close and re-open Unity to have it display the directory and its contents.
* **Usage:** The package provides two shaders specifically built for the universal render pipeline:
* `Universal Render Pipeline/Spine/Skeleton`, as a universal variant of the `Spine/Skeleton` shader,
* `Universal Render Pipeline/Spine/Skeleton Lit`, as a universal variant of the `Spine/Skeleton Lit` shader,
* `Universal Render Pipeline/Spine/Sprite`, as a universal variant of the `Spine/Sprite/Vertex Lit` and `Pixel Lit` shaders, which were not functioning in the universal render pipeline,
* `Universal Render Pipeline/2D/Spine/Skeleton Lit`, as a universal 2D Renderer variant of the `Spine/Skeleton Lit` shader, and
* `Universal Render Pipeline/2D/Spine/Sprite`, as a universal 2D Renderer variant of the `Spine/Sprite/Vertex Lit` and `Pixel Lit` shaders.
The shaders can be assigned to materials as usual and will respect your settings of the assigned `UniversalRenderPipelineAsset` under `Project Settings - Graphics`.
* **Restrictions** As all Spine shaders, the URP shaders **do not support `Premultiply alpha` (PMA) atlas textures in Linear color space**. Please export your atlas textures as `straight alpha` textures with disabled `Premultiply alpha` setting when using Linear color space. You can check the current color space via `Project Settings - Player - Other Settings - Color Space.`.
* **Example:** You can find an example scene in the package under `com.esotericsoftware.spine.urp-shaders-3.8/Examples/URP Shaders.unity` that demonstrates usage of the URP shaders.
* Spine Preferences now provide an **`Atlas Texture Settings`** parameter for applying customizable texture import settings at all newly imported Spine atlas textures.
When exporting atlas textures from Spine with `Premultiply alpha` enabled (the default), you can leave it at `PMATexturePreset`. If you have disabled `Premultiply alpha`, set it to the included `StraightAlphaTexturePreset` asset. You can also create your own `TextureImporter` `Preset` asset and assign it here (include `PMA` or `Straight` in the name). In Unity versions before 2018.3 you can use `Texture2D` template assets instead of the newer `Preset` assets. Materials created for imported textures will also have the `Straight Alpha Texture` parameter configured accordingly.
* All `Sprite` shaders (including URP and LWRP extension packages) now provide an additional `Fixed Normal Space` option `World-Space`. PReviously options were limited to `View-Space` and `Model-Space`.
* `SkeletonGraphic` now fully supports [`SkeletonUtility`](http://esotericsoftware.com/spine-unity#SkeletonUtility) for generating a hierarchy of [`SkeletonUtilityBones`](http://esotericsoftware.com/spine-unity#SkeletonUtilityBone) in both modes `Follow` and `Override`. This also enables creating hinge chain physics rigs and using `SkeletonUtilityConstraints` such as `SkeletonUtilityGroundConstraint` and `SkeletonUtilityEyeConstraint` on `SkeletonGraphic`.
* Added `OnMeshAndMaterialsUpdated` callback event to `SkeletonRenderer` and `SkeletonGraphic`. It is issued at the end of `LateUpdate`, before rendering.
* Added `Skeleton-OutlineOnly` single pass shader to LWRP and URP extension modules. It can be assigned to materials as `Universal Render Pipeline/Spine/Outline/Skeleton-OutlineOnly`. This allows for separate outline child *GameObjects* that reference the existing Mesh of their parent, and re-draw the mesh using this outline shader.
* Added example component `RenderExistingMesh` to render a mesh again with different materials, as required by the new `Skeleton-OutlineOnly` shaders.
In URP the outline has to be rendered via a separate GameObject as URP does not allow multiple render passes. To add an outline to your SkeletenRenderer:
1) Add a child GameObject and move it a bit back (e.g. position Z = 0.01).
2) Add a `RenderExistingMesh` component, provided in the `Spine Examples/Scripts/Sample Components` directory.
3) Copy the original material, add *_Outline* to its name and set the shader to `Universal Render Pipeline/Spine/Outline/Skeleton-OutlineOnly`.
4) Assign this *_Outline* material at the `RenderExistingMesh` component under *Replacement Materials*.
* Added `Outline Shaders URP` example scene to URP extension module to demonstrate the above additions.
* Added support for Unity's [`SpriteAtlas`](https://docs.unity3d.com/Manual/class-SpriteAtlas.html) as atlas provider (as an alternative to `.atlas.txt` and `.png` files) alongside a skeleton data file. There is now an additional `Spine SpriteAtlas Import` tool window accessible via `Window - Spine - SpriteAtlas Import`. Additional information can be found in a new section on the [spine-unity documentation page](http://esotericsoftware.com/spine-unity#Advanced---Using-Unity-SpriteAtlas-as-Atlas-Provider).
* Added support for **multiple atlas textures at `SkeletonGraphic`**. You can enable this feature by enabling the parameter `Multiple CanvasRenders` in the `Advanced` section of the `SkeletonGraphic` Inspector. This automatically creates the required number of child `CanvasRenderer` GameObjects for each required draw call (submesh).
* Added support for **Render Separator Slots** at `SkeletonGraphic`. Render separation can be enabled directly in the `Advanced` section of the `SkeletonGraphic` Inspector, it does not require any additional components (like `SkeletonRenderSeparator` or `SkeletonPartsRenderer` for `SkeletonRenderer` components). When enabled, additional separator GameObjects will be created automatically for each separation part, and `CanvasRenderer` GameObjects re-parented to them accordingly. The separator GameObjects can be moved around and re-parented in the hierarchy according to your requirements to achieve the desired draw order within your `Canvas`. A usage example can be found in the updated `Spine Examples/Other Examples/SkeletonRenderSeparator` scene.
* Added `SkeletonGraphicCustomMaterials` component, providing functionality to override materials and textures of a `SkeletonGraphic`, similar to `SkeletonRendererCustomMaterials`. Note: overriding materials or textures per slot is not provided due to structural limitations.
* Added **Root Motion support** for `SkeletonAnimation`, `SkeletonMecanim` and `SkeletonGraphic` via new components `SkeletonRootMotion` and `SkeletonMecanimRootMotion`. The `SkeletonAnimation` and `SkeletonGraphic` component Inspector now provides a line `Root Motion` with `Add Component` and `Remove Component` buttons to add/remove the new `SkeletonRootMotion` component to your GameObject. The `SkeletonMecanim` Inspector detects whether root motion is enabled at the `Animator` component and adds a `SkeletonMecanimRootMotion` component automatically.
* `SkeletonMecanim` now provides an additional `Custom MixMode` parameter under `Mecanim Translator`. It is enabled by default in version 3.8 to maintain current behaviour, using the set `Mix Mode` for each Mecanim layer. When disabled, `SkeletonMecanim` will use the recommended `MixMode` according to the layer blend mode. Additional information can be found in the [Mecanim Translator section](http://esotericsoftware.com/spine-unity#Parameters-for-animation-blending-control) on the spine-unity documentation pages.
* Added **SkeletonGraphic Timeline support**. Added supprot for multi-track Timeline preview in the Editor outside of play mode (multi-track scrubbing). See the [Timeline-Extension-UPM-Package](http://esotericsoftware.com/spine-unity#Timeline-Extension-UPM-Package) section of the spine-unity documentation for more information.
* Added support for double-sided lighting at all `SkeletonLit` shaders (including URP and LWRP packages).
* Added frustum culling update mode parameters `Update When Invisible` (Inspector parameter) and `UpdateMode` (available via code) to all Skeleton components. This provides a simple way to disable certain updates when the `Renderer` is no longer visible (outside all cameras, culled in frustum culling). The new `UpdateMode` property allows disabling updates at a finer granularity level than disabling the whole component. Available modes are: `Nothing`, `OnlyAnimationStatus`, `EverythingExceptMesh` and `FullUpdate`.
* Added a new `Spine/Outline/OutlineOnly-ZWrite` shader to provide correct outline-only rendering. Note: the shader requires two render passes and is therefore not compatible with URP. The `Spine Examples/Other Examples/Outline Shaders` example scene has been updated to demonstrate the new shader.
* Added `OnMeshAndMaterialsUpdated` callback event to `SkeletonRenderSeparator` and `SkeletonPartsRenderer`. It is issued at the end of `LateUpdate`, before rendering.
* Added `Root Motion Scale X/Y` parameters to `SkeletonRootMotionBase` subclasses (`SkeletonRootMotion` and `SkeletonMecanimRootMotion`). Also providing `AdjustRootMotionToDistance()` and other methods to allow for easy delta compensation. Delta compensation can be used to e.g. stretch a jump to a given distance. Root motion can be adjusted at the start of an animation or every frame via `skeletonRootMotion.AdjustRootMotionToDistance(targetPosition - transform.position, trackIndex);`.
* Now providing a `Canvas Group Tint Black` parameter at the `SkeletonGraphic` Inspector in the `Advanced` section. When using the `Spine/SkeletonGraphic Tint Black` shader you can enable this parameter to receive proper blending results when using `Additive` blend mode under a `CanvasGroup`. Be sure to also have the parameter `CanvasGroup Compatible` enabled at the shader. Note that the normal `Spine/SkeletonGraphic` does not support `Additive` blend mode at a `CanvasGroup`, as it requires additional shader channels to work.
* Added `Mix and Match Skins` example scene to demonstrate how the 3.8 Skin API and combining skins can be used for a wardrobe and equipment use case.
* Spine Timeline Extensions: Added `Hold Previous` parameter at `SpineAnimationStateClip`.
* Added more warning messages at incompatible SkeletonRenderer/SkeletonGraphic Component vs Material settings. They appear both as an info box in the Inspector as well as upon initialization in the Console log window. The Inspector box warnings can be disabled via `Edit - Preferences - Spine`.
* Now providing `BeforeApply` update callbacks at all skeleton animation components (`SkeletonAnimation`, `SkeletonMecanim` and `SkeletonGraphic`).
* Added `BoundingBoxFollowerGraphic` component. This class is a counterpart of `BoundingBoxFollower` that can be used with `SkeletonGraphic`.
* Added Inspector context menu functions `SkeletonRenderer - Add all BoundingBoxFollower GameObjects` and `SkeletonGraphic - Add all BoundingBoxFollowerGraphic GameObjects` that automatically generate bounding box follower GameObjects for every `BoundingBoxAttachment` for all skins of a skeleton.
* `GetRemappedClone()` now provides an additional parameter `pivotShiftsMeshUVCoords` for `MeshAttachment` to prevent uv shifts at a non-central Sprite pivot. This parameter defaults to `true` to maintain previous behaviour.
* `SkeletonRenderer` components now provide an additional update mode `Only Event Timelines` at the `Update When Invisible` property. This mode saves additional timeline updates compared to update mode `Everything Except Mesh`.
* Now all URP (Universal Render Pipeline) and LWRP (Lightweight Render Pipeline) shaders support SRP (Scriptable Render Pipeline) batching. See [Unity SRPBatcher documentation pages](https://docs.unity3d.com/Manual/SRPBatcher.html) for additional information.
* Sprite shaders now provide four `Diffuse Ramp` modes as an Inspector Material parameter: `Hard`, `Soft`, `Old Hard` and `Old Soft`. In spine-unity 3.8 it defaults to `Old Hard` to keep the behaviour of existing projects unchanged. Note that `Old Hard` and `Old Soft` ramp versions were using only the right half of the ramp texture, and additionally multiplying the light intensity by 2, both leading to brighter lighting than without a ramp texture active. The new ramp modes `Hard` and `Soft` use the full ramp texture and do not modify light intensity, being consistent with lighting without a ramp texture active.
* Added **native support for slot blend modes** `Additive`, `Multiply` and `Screen` with automatic assignment at newly imported skeleton assets. `BlendModeMaterialAssets` are now obsolete and replaced by the native properties at `SkeletonDataAsset`. The `SkeletonDataAsset` Inspector provides a new `Blend Modes - Upgrade` button to upgrade an obsolete `BlendModeMaterialAsset` to the native blend modes properties. This upgrade will be performed automatically on imported and re-imported assets in Unity 2020.1 and newer to prevent reported `BlendModeMaterialAsset` issues in these Unity versions. spine-unity 4.0 and newer will automatically perform this upgrade regardless of the Unity version.
* `BoneFollower` and `BoneFollowerGraphic` components now provide better support for following bones when the skeleton's Transform is not the parent of the follower's Transform. Previously e.g. rotating a common parent Transform did not lead to the desired result, as well as negatively scaling a skeleton's Transform when it is not a parent of the follower's Transform.
* URP and LWRP `Sprite` and `SkeletonLit` shaders no longer require `Advanced - Add Normals` enabled to properly cast and receive shadows. It is recommended to disable `Add Normals` if normals are otherwise not needed.
* Added an example component `RootMotionDeltaCompensation` located in `Spine Examples/Scripts/Sample Components` which can be used for applying simple delta compensation. You can enable and disable the component to toggle delta compensation of the currently playing animation on and off.
* Root motion delta compensation now allows to only adjust X or Y components instead of both. Adds two parameters to `SkeletonRootMotionBase.AdjustRootMotionToDistance()` which default to adjusting both X and Y as before. The `RootMotionDeltaCompensation` example component exposes these parameters as public attributes.
* Root motion delta compensation now allows to also add translation root motion to e.g. adjust a horizontal jump upwards or downwards over time. This is necessary because a Y root motion of zero cannot be scaled to become non-zero.
* `Attachment.GetRemappedClone(Sprite)` method now provides an additional optional parameter `useOriginalRegionScale`. When set to `true`, the replaced attachment's scale is used instead of the Sprite's `Pixel per Unity` setting, allowing for more consistent scaling. *Note:* When remapping Sprites, be sure to set the Sprite's `Mesh Type` to `Full Rect` and not `Tight`, otherwise the scale will be wrong.
* **Changes of default values**
* `SkeletonMecanim`'s `Layer Mix Mode` now defaults to `MixMode.MixNext` instead of `MixMode.MixAlways`.
* `BlendModeMaterialAsset` and it's instance `Default BlendModeMaterials.asset` now have `Apply Additive Material` set to `true` by default in order to apply all blend modes by default.
* **Deprecated**
* Deprecated `Modules/SlotBlendModes/SlotBlendModes` component. Changed namespace from `Spine.Unity.Modules` to `Spine.Unity.Deprecated`. Moved to `Deprecated/SlotBlendModes`.
* **Restructuring (Non-Breaking)**
Note: The following changes will most likely not affect users of the Spine-Unity runtime as the API remains unchanged and no references are invalidated.
* Removed duplicates of `.cginc` files in `Modules/Shaders/Sprite` that were also present in the `Modules/Shaders/Sprite/CGIncludes` directory.
* Moved shaders from `Modules/Shaders` to `Shaders` directory.
* Moved shaders from `Modules/SkeletonGraphic/Shaders` to `Shaders/SkeletonGraphic`.
* Renamed shader `Shaders/Spine-SkeletonLit.shader` to `Shaders/Spine-Skeleton-Lit.shader`.
* Moved components from `SkeletonGraphic` to `Components` and `Components/Following` except for `SkeletonGraphicMirror` which was moved to `Spine Examples/Scripts/Sample Components`.
* Moved `BoneFollower`, `BoneFollowerGraphic` and `PointFollower` from `Components` directory to `Components/Following`.
* Moved `BoundingBoxFollower` component from `Modules/BoundingBoxFollower` to `Components/Following`.
* Moved `Modules/SkeletonRenderSeparator` directory to `Components/SkeletonRenderSeparator`.
* Moved `Modules/CustomMaterials` directory to `Components/SkeletonRendererCustomMaterials`.
* Moved `Asset Types/BlendModeMaterialsAsset.cs` class, `Shaders/BlendModes/Default BlendModeMaterials.asset` and materials from `Shaders/BlendModes` to `SkeletonDataModifierAssets/BlendModeMaterials` directory.
* Moved `Modules/Ghost` directory to `Spine Examples/Scripts/Sample Components/Ghost`.
* Moved `Modules/SkeletonUtility Modules` directory to `Spine Examples/Scripts/Sample Components/SkeletonUtility Modules`.
* Moved `Modules/AnimationMatchModifier` directory to `Spine Examples/Scripts/MecanimAnimationMatchModifier`.
* Moved `SkeletonRagdoll` and `SkeletonRagdoll2D` components from `Modules/Ragdoll` directory to `Spine Examples/Scripts/Sample Components/SkeletonUtility Modules`.
* Moved `AttachmentTools.cs` to `Utility` directory.
* Split the file `AttachmentTools` into 5 separate files for each contained class. No namespace or other API changes performed.
* Split the file `Mesh Generation/SpineMesh` into 4 separate files for each contained class. No namespace or other API changes performed.
* Moved `SkeletonExtensions.cs` to `Utility` directory.
* Moved `Modules/YieldInstructions` directory to `Utility/YieldInstructions`.
* Moved corresponding editor scripts of the above components to restructured directories as well.
* Renamed inspector editor class `PointFollowerEditor` to `PointFollowerInspector` for consistency reasons.
### XNA/MonoGame
* Updated to latest MonoGame version 3.7.1
* Rewrote example project to be cleaner and better demonstrate basic Spine features.
* Added mix-and-match example to demonstrate the new Skin API.
* Added normalmap support via `SpineEffectNormalmap` and support for loading multiple texture layers following a suffix-pattern. Please see the example code on how to use them.
## Java
* **Breaking changes**
* Renamed `Slot#getAttachmentVertices()` to `Slot#getDeform()`.
* Changed the `.json` curve format and added more assumptions for omitted values, reducing the average size of JSON exports.
* Renamed `Skin#addAttachment()` to `Skin#setAttachment()`.
* Removed `VertexAttachment#applyDeform()` and replaced it with `VertexAttachment#deformAttachment`. The attachment set on this field is used to decide if a `DeformTimeline` should be applied to the attachment active on the slot to which the timeline is applied.
* Removed `inheritDeform` field, getter, and setter from `MeshAttachment`.
* Changed `.skel` binary format, added a string table. References to strings in the data resolve to this string table, reducing storage size of binary files considerably.
* `JsonRollback` tool now converts from 3.8 JSON to 3.7.
* Changed the `.json` and `.skel` file formats to accomodate the new feature and file size optimiations. Old projects must be exported with Spine 3.8.20+ to be compatible with the 3.8 Spine runtimes.
* **Additions**
* Added `x` and `y` coordinates for setup pose AABB in `SkeletonData`.
* Added support for rotated mesh region UVs.
* Added skin-specific bones and constraints which are only updated if the skeleton's current skin contains them.
* Improved Skin API to make it easier to handle mix-and-match use cases.
* Added `Skin#getAttachments()`. Returns all attachments in the skin.
* Added `Skin#getAttachments(int slotIndex)`. Returns all attachements in the skin for the given slot index.
* Added `Skin#addSkin(Skin skin)`. Adds all attachments, bones, and skins from the specified skin to this skin.
* Added `Skin#copySkin(Skin skin)`. Adds all attachments, bones, and skins from the specified skin to this skin. `VertexAttachment` are shallowly copied and will retain any parent mesh relationship. All other attachment types are deep copied.
* Added `Attachment#copy()` to all attachment type implementations. This lets you deep copy an attachment to modify it independently from the original, i.e. when programmatically changing texture coordinates or mesh vertices.
* Added `MeshAttachment#newLinkedMesh()`, creates a linked mesh linkted to either the original mesh, or the parent of the original mesh.
* Added IK softness.
### libGDX
* `SkeletonViewer` can load a skeleton by specifying it as the first argument on the command line.
* Added mix-and-match example to demonstrate the new Skin API.
## Lua
* **Breaking changes**
* Renamed `Slot:getAttachmentVertices()` to `Slot#deform`.
* Changed the `.json` curve format and added more assumptions for omitted values, reducing the average size of JSON exports.
* Renamed `Skin:addAttachment()` to `Skin#setAttachment()`.
* Removed `VertexAttachment:applyDeform()` and replaced it with `VertexAttachment#deformAttachment`. The attachment set on this field is used to decide if a `DeformTimeline` should be applied to the attachment active on the slot to which the timeline is applied.
* Removed `inheritDeform` field, getter, and setter from `MeshAttachment`.
* Changed the `.json` file format to accomodate the new feature and file size optimiations. Old projects must be exported with Spine 3.8.20+ to be compatible with the 3.8 Spine runtimes.
* **Additions**
* Added `x` and `y` coordinates for setup pose AABB in `SkeletonData`.
* Added support for rotated mesh region UVs.
* Added skin-specific bones and constraints which are only updated if the skeleton's current skin contains them.
* Improved Skin API to make it easier to handle mix-and-match use cases.
* Added `Skin:getAttachments()`. Returns all attachments in the skin.
* Added `Skin:getAttachments(slotIndex)`. Returns all attachements in the skin for the given slot index.
* Added `Skin:addSkin(Skin skin)`. Adds all attachments, bones, and skins from the specified skin to this skin.
* Added `Skin:copySkin(Skin skin)`. Adds all attachments, bones, and skins from the specified skin to this skin. `VertexAttachment` are shallowly copied and will retain any parent mesh relationship. All other attachment types are deep copied.
* Added `Attachment:copy()` to all attachment type implementations. This lets you deep copy an attachment to modify it independently from the original, i.e. when programmatically changing texture coordinates or mesh vertices.
* Added `MeshAttachment:newLinkedMesh()`, creates a linked mesh linkted to either the original mesh, or the parent of the original mesh.
* Added IK softness.
### Love2D
* Added support for 0-1 RGBA color component range change in Löve 0.11+. Older Löve versions using the 0-255 range are still supported!
* Added mix-and-match example to demonstrate the new Skin API.
### Corona
* Added mix-and-match example to demonstrate the new Skin API.
## Typescript/Javascript
* **Breaking changes**
* Renamed `MixDirection.in/out` to `MixDirection.mixIn/mixOut` as it was crashing a JS compressor.
* Renamed `Slot#getAttachmentVertices()` to `Slot#getDeform()`.
* Changed the `.json` curve format and added more assumptions for omitted values, reducing the average size of JSON exports.
* Renamed `Skin#addAttachment()` to `Skin#setAttachment()`.
* Removed `VertexAttachment#applyDeform()` and replaced it with `VertexAttachment#deformAttachment`. The attachment set on this field is used to decide if a `DeformTimeline` should be applied to the attachment active on the slot to which the timeline is applied.
* Removed `inheritDeform` field, getter, and setter from `MeshAttachment`.
* Changed `.skel` binary format, added a string table. References to strings in the data resolve to this string table, reducing storage size of binary files considerably.
* Changed the `.json` and `.skel` file formats to accomodate the new feature and file size optimiations. Old projects must be exported with Spine 3.8.20+ to be compatible with the 3.8 Spine runtimes.
* Updated runtime to be compatible with TypeScript 3.6.3.
* **Additions**
* Added support for loading binary data via `AssetManager#loadBinary()`. `AssetManager#get()` will return a `Uint8Array` for such assets.
* Added support for loading binaries via new `SkeletonBinary`. Parses a `Uint8Array`.
* Added `x` and `y` coordinates for setup pose AABB in `SkeletonData`.
* Added support for rotated mesh region UVs.
* Added skin-specific bones and constraints which are only updated if the skeleton's current skin contains them.
* Improved Skin API to make it easier to handle mix-and-match use cases.
* Added `Skin#getAttachments()`. Returns all attachments in the skin.
* Added `Skin#getAttachments(slotIndex: number)`. Returns all attachements in the skin for the given slot index.
* Added `Skin#addSkin(skin: Skin)`. Adds all attachments, bones, and skins from the specified skin to this skin.
* Added `Skin#copySkin(skin: Skin)`. Adds all attachments, bones, and skins from the specified skin to this skin. `VertexAttachment` are shallowly copied and will retain any parent mesh relationship. All other attachment types are deep copied.
* Added `Attachment#copy()` to all attachment type implementations. This lets you deep copy an attachment to modify it independently from the original, i.e. when programmatically changing texture coordinates or mesh vertices.
* Added `MeshAttachment#newLinkedMesh()`, creates a linked mesh linkted to either the original mesh, or the parent of the original mesh.
* Added IK softness.
* Added `AssetManager.setRawDataURI(path, data)`. Allows to embed data URIs for skeletons, atlases and atlas page images directly in the HTML/JS without needing to load it from a separate file.
### WebGL backend
* `Input` can now take a partially defined implementation of `InputListener`.
* Added mix-and-match example to demonstrate the new Skin API.
### Canvas backend
### Three.js backend
* `SkeletonMesh` now takes an optional `SkeletonMeshMaterialParametersCustomizer` function that allows you to modify the `ShaderMaterialParameters` before the material is finalized. Use it to modify things like THREEJS' `Material.depthTest` etc. See #1590.
### Player
* `SpinePlayer#setAnimation()` can now be called directly to set the animation being displayed.
* The player supports loading `.skel` binary skeleton files by setting the `SpinePlayerConfig#skelUrl` field instead of `SpinePlayerConfig#jsonUrl`.
* Added `SpinePlayerConfig#rawDataURIs`. Allows to embed data URIs for skeletons, atlases and atlas page images directly in the HTML/JS without needing to load it from a separate file. See the example for a demonstration.
# 3.7
## AS3
* **Breaking changes**
* The completion event will fire for looped 0 duration animations every frame.
* `MixPose` is now called `MixBlend`
* Skeleton `flipX/flipY` has been replaced with `scaleX/scaleY`. This cleans up applying transforms and is more powerful. Allows scaling a whole skeleton which has bones that disallow scale inheritance
* Mix time is no longer affected by `TrackEntry#timeScale`. See https://github.com/EsotericSoftware/spine-runtimes/issues/1194
* **Additions**
* Added additive animation blending. When playing back multiple animations on different tracks, where each animation modifies the same skeleton property, the results of tracks with lower indices are discarded, and only the result from the track with the highest index is used. With animation blending, the results of all tracks are mixed together. This allows effects like mixing multiple facial expressions (angry, happy, sad) with percentage mixes. By default the old behaviour is retained (results from lower tracks are discarded). To enable additive blending across animation tracks, call `TrackEntry#setMixBlend(MixBlend.add)` on each track. To specify the blend percentage, set `TrackEntry#alpha`. See http://esotericsoftware.com/forum/morph-target-track-animation-mix-mode-9459 for a discussion.
* Support for stretchy IK
* Support for audio events, see `audioPath`, `volume` and `balance` fields on event (data).
* `TrackEntry` has an additional field called `holdPrevious`. It can be used to counter act a limitation of `AnimationState` resulting in "dipping" of parts of the animation. For a full discussion of the problem and the solution we've implemented, see this [forum thread](http://esotericsoftware.com/forum/Probably-Easy-Animation-mixing-with-multiple-tracks-10682?p=48130&hilit=holdprevious#p48130).
### Starling
* Added support for vertex effects. See `RaptorExample.as`
* Added 'getTexture()' method to 'StarlingTextureAtlasAttachmentLoader'
* Breaking change: if a skeleton requires two color tinting, you have to enable it via `SkeletonSprite.twoColorTint = true`. In this case the skeleton will use the `TwoColorMeshStyle`, which internally uses a different vertex layout and shader. This means that skeletons with two color tinting enabled will break batching and hence increase the number of draw calls in your app.
* Added `VertexEffect` and implementations `JitterEffect` and `SwirlEffect`. Allows you to modify vertices before they are submitted for drawing. See Starling changes.
* Fix issues with StarlingAtlasAttachmentLoader, see https://github.com/EsotericSoftware/spine-runtimes/issues/939
* Fix issues with region trimming support, see https://github.com/EsotericSoftware/spine-runtimes/commit/262bc26c64d4111002d80e201cb1a3345e6727df
* Added support for overriding `StarlingAtlasAttachmentLoader#getTexture()`, see https://github.com/EsotericSoftware/spine-runtimes/commit/ea7dbecb98edc74e439aa9ef90dcf6eed865f718
* Texture atlas operations are no longer handled in `Starling#newRegionAttachment` and `Starling#newMeshAttachment` but delegated to the atlas.
* Added sample for additive animation blending, see https://github.com/EsotericSoftware/spine-runtimes/blob/6a556de01429878df47bb276a97959a8bdbbe32f/spine-starling/spine-starling-example/src/spine/examples/OwlExample.as
* Added sample on how to use bounding box attachment vertices https://github.com/EsotericSoftware/spine-runtimes/commit/e20428b02699226164fa73ba4b12f7d029ae6f4d
* Fully transparent meshes are not submitted for rendering.
* No hit-tests are performed when a skeleton is invisible.
## C
* **Breaking changes**
* Listeners on `spAnimationState` and `spTrackEntry` will now also be called if a track entry gets disposed as part of disposing an animation state.
* The completion event will fire for looped 0 duration animations every frame.
* The spine-cocos2dx and spine-ue4 runtimes are now based on spine-cpp. See below for changes.
* Skeleton `flipX/flipY` has been replaced with `scaleX/scaleY`. This cleans up applying transforms and is more powerful. Allows scaling a whole skeleton which has bones that disallow scale inheritance
* Mix time is no longer affected by `TrackEntry#timeScale`. See https://github.com/EsotericSoftware/spine-runtimes/issues/1194
* `spMeshAttachment` has two new fields `regionTextureWith` and `regionTextureHeight`. These must be set in custom attachment loader. See `AtlasAttachmentLoader`.
* **Additions**
* Added support for local and relative transform constraint calculation, including additional fields in `spTransformConstraintData`.
* `Animation#apply` and `Timeline#apply`` now take enums `MixPose` and `MixDirection` instead of booleans
* Added `spVertexEffect` and corresponding implementations `spJitterVertexEffect` and `spSwirlVertexEffect`. Create/dispose through the corresponding `spXXXVertexEffect_create()/dispose()` functions. Set on framework/engine specific renderer.
* Functions in `extension.h` are not prefixed with `_sp` instead of just `_` to avoid interference with other libraries.
* Introduced `SP_API` macro. Every spine-c function is prefixed with this macro. By default, it is an empty string. Can be used to markup spine-c functions with e.g. ``__declspec` when compiling to a dll or linking to that dll.
* Added `void *userData` to `spAnimationState`to be consumed in callbacks.
* Added additive animation blending. When playing back multiple animations on different tracks, where each animation modifies the same skeleton property, the results of tracks with lower indices are discarded, and only the result from the track with the highest index is used. With animation blending, the results of all tracks are mixed together. This allows effects like mixing multiple facial expressions (angry, happy, sad) with percentage mixes. By default the old behaviour is retained (results from lower tracks are discarded). To enable additive blending across animation tracks, call `spTrackEntry->mixBlend = SP_MIXBLEND_ADD)` on each track. To specify the blend percentage, set `spTrackEntry->alpha`. See http://esotericsoftware.com/forum/morph-target-track-animation-mix-mode-9459 for a discussion.
* Optimized attachment lookup to give a 40x speed-up. See https://github.com/EsotericSoftware/spine-runtimes/commit/cab81276263890b65d07fa2329ace16db1e365ff
* Support for stretchy IK
* Support for audio events, see `audioPath`, `volume` and `balance` fields on event (data).
* `spTrackEntry` has an additional field called `holdPrevious`. It can be used to counter act a limitation of `AnimationState` resulting in "dipping" of parts of the animation. For a full discussion of the problem and the solution we've implemented, see this [forum thread](http://esotericsoftware.com/forum/Probably-Easy-Animation-mixing-with-multiple-tracks-10682?p=48130&hilit=holdprevious#p48130).
### Cocos2d-Objc
* Added vertex effect support to modify vertices of skeletons on the CPU. See `RaptorExample.m`.
* Explanation how to handle ARC, see https://github.com/EsotericSoftware/spine-runtimes/commit/a4f122b08c5e2a51d6aad6fc5a947f7ec31f2eb8
* The super class `::update()` method of `SkeletonRenderer` is now called, see https://github.com/EsotericSoftware/spine-runtimes/commit/f7bb98185236a6d8f35bfefc70afe4f31e9ec9d2
* Added improved tint-black shader.
### SFML
* `spine-sfml.h` no longer defines `SPINE_SHORT_NAMES` to avoid collisions with other APIs. See #1058.
* Added support for vertex effects. See raptor example.
* Added premultiplied alpha support to `SkeletonDrawable`. Use `SkeletonDrawable::setUsePremultipliedAlpha()`, see https://github.com/EsotericSoftware/spine-runtimes/commit/34086c1f41415309b2ecce86055f6656fcba2950
* Added additive animation blending sample, see https://github.com/EsotericSoftware/spine-runtimes/blob/b7e712d3ca1d6be3ebcfe3254dc2cea9c44dda71/spine-sfml/example/main.cpp#L369
## C++
* ** Additions **
* Added C++ Spine runtime. See the [spine-cpp Runtime Guide](https://esotericsoftware.com/spine-cpp) for more information on spine-cpp.
* Added parsing of non-essential data (fps, images path, audio path) to for `.json`/`.skel` parsers.
### Cocos2d-x
* Added ETC1 alpha support, thanks @halx99! Does not work when two color tint is enabled.
* Added `spAtlasPage_setCustomTextureLoader()` which let's you do texture loading manually. Thanks @jareguo.
* Added `SkeletonRenderer:setSlotsRange()` and `SkeletonRenderer::createWithSkeleton()`. This allows you to split rendering of a skeleton up into multiple parts, and render other nodes in between. See `SkeletonRendererSeparatorExample.cpp` for an example.
* Fully transparent attachments will not be rendered, improving rendering performance.
* Added improved tint-black shader.
* Updated to cocos2d-x 3.16
* The skeleton setup pose and world transform are now calculated on initialization to avoid flickering on start-up.
* Updated to cocos2d-x 3.17.1
* **Breaking change**: Switched from [spine-c](spine-c) to [spine-cpp](spine-cpp) as the underlying Spine runtime. See the [spine-cpp Runtime Guide](https://esotericsoftware.com/spine-cpp) for more information on spine-cpp.
* Added `Cocos2dAttachmentLoader` to be used when constructing an `Atlas`. Used by default by `SkeletonAnimation` and `SkeletonRenderer` when creating instances via the `createXXX` methods.
* All C structs and enums `spXXX` have been replaced with their C++ equivalents `spine::XXX` in all public interfaces.
* All instantiations via `new` of C++ classes from spine-cpp should contain `(__FILE__, __LINE__)`. This allows the tracking of instantations and detection of memory leaks via the `spine::DebugExtension`.
### SFML
* Create a second SFML backend using [spine-cpp](spine-cpp/). See the [spine-cpp Runtime Guide](https://esotericsoftware.com/spine-cpp) for more information on spine-cpp.
* Added support for vertex effects. See raptor example.
* Added premultiplied alpha support to `SkeletonDrawable`. Use `SkeletonDrawable::setUsePremultipliedAlpha()`, see https://github.com/EsotericSoftware/spine-runtimes/commit/34086c1f41415309b2ecce86055f6656fcba2950
* Added additive animation blending sample, see https://github.com/EsotericSoftware/spine-runtimes/blob/b7e712d3ca1d6be3ebcfe3254dc2cea9c44dda71/spine-sfml/example/main.cpp#L369
### UE4
* spine-c is now exposed from the plugin shared library on Windows via __declspec.
* Updated to Unreal Engine 4.18
* Added C++ example, see https://github.com/EsotericSoftware/spine-runtimes/commit/15011e81b7061495dba45e28b4d3f4efb10d7f40
* `SkeletonRendererComponent` generates collision meshes by default.
* Disabled generation of collision meshes by `SkeletonRendererComponent`. Both `ProceduralMeshComponent` and `RuntimeMeshComponent` have a bug that generates a new PhysiX file every frame per component. Users are advised to add a separate collision shape to the root scene component of an actor instead.
* Using UE4 `FMemory` allocator by default. This should fix issues on some consoles.
* **Breaking change** moved away from `RuntimeMeshComponent`, as its maintainance has seized, back to `ProceduralMeshComponent`. Existing projects should just work. However, if you run into issues, you may have to remove the old `SpineSkeletonRendererComponent` and add a new one to your existing actors.
* **Breaking change** due to the removal of `RuntimeMeshComponent` and reversal to `ProceduralMeshComponent`, two color tinting is currently not supported. `ProceduralMeshComponent` does not support enough vertex attributes for us to encode the second color in the vertex stream. You can remove the `RuntimeMeshComponent/` directory from your plugins directory and remove the component from any `build.cs` files that may reference it.
* **Breaking change**: Switched from [spine-c](spine-c) to [spine-cpp](spine-cpp) as the underlying Spine runtime. See the [spine-cpp Runtime Guide](https://esotericsoftware.com/spine-cpp) for more information on spine-cpp.
* All C structs and enums `spXXX` have been replaced with their C++ equivalents `spine::XXX` in all public interfaces.
* All instantiations via `new` of C++ classes from spine-cpp should contain `(__FILE__, __LINE__)`. This allows the tracking of instantations and detection of memory leaks via the `spine::DebugExtension`.
* Updated to Unreal Engine 4.20 (samples require 4.17+), see the `spine-ue4/Plugins/SpinePlugin/Source/SpinePlugin/SpinePlugin.build.cs` file on how to compile in 4.20 with the latest UBT API changes.
* Updated to Unreal Engine 4.21 (samples require 4.21).
* **Breaking change**: `UBoneDriverComponent` and `UBoneFollowerComponent` are now `USceneComponent` instead of `UActorComponent`. They either update only themselves, or also the owning `UActor`, depending on whether the new flag `UseComponentTransform` is set. See https://github.com/EsotericSoftware/spine-runtimes/pull/1175
* Added query methods for slots, bones, skins and animations to `SpineSkeletonComponent` and `UTrackEntry`. These allow you to query these objects by name in both C++ and blueprints.
* Added `Preview Animation` and `Preview Skin` properties to `SpineSkeletonAnimationComponent`. Enter an animation or skin name to live-preview it in the editor. Enter an empty string to reset the animation or skin.
## C# ##
* **Breaking changes**
* The completion event will fire for looped 0 duration animations every frame.
* Skeleton `flipX/flipY` has been replaced with `scaleX/scaleY`. This cleans up applying transforms and is more powerful. Allows scaling a whole skeleton which has bones that disallow scale inheritance
* Mix time is no longer affected by `TrackEntry#timeScale`. See https://github.com/EsotericSoftware/spine-runtimes/issues/1194
* **Additions**
* Added additive animation blending. When playing back multiple animations on different tracks, where each animation modifies the same skeleton property, the results of tracks with lower indices are discarded, and only the result from the track with the highest index is used. With animation blending, the results of all tracks are mixed together. This allows effects like mixing multiple facial expressions (angry, happy, sad) with percentage mixes. By default the old behaviour is retained (results from lower tracks are discarded). To enable additive blending across animation tracks, call `TrackEntry#MixBlend = MixBlend.add` on each track. To specify the blend percentage, set `TrackEntry#Alpha`. See http://esotericsoftware.com/forum/morph-target-track-animation-mix-mode-9459 for a discussion.
* Support for stretchy IK
* Support for audio events, see `audioPath`, `volume` and `balance` fields on event (data).
* `TrackEntry` has an additional field called `holdPrevious`. It can be used to counter act a limitation of `AnimationState` resulting in "dipping" of parts of the animation. For a full discussion of the problem and the solution we've implemented, see this [forum thread](http://esotericsoftware.com/forum/Probably-Easy-Animation-mixing-with-multiple-tracks-10682?p=48130&hilit=holdprevious#p48130).
### Unity
* **Runtime and Editor, and Assembly Definition** Files and folders have been reorganized into "Runtime" and "Editor". Each of these have an `.asmdef` file that defines these separately as their own assembly in Unity *(Note: Spine `.asmdef` files are currently deactivated to `.txt` extension, see below)*. For projects not using assembly definition, you may delete the `.asmdef` files. These assembly definitions will be ignored by older versions of Unity that don't support it.
* In this scheme, the entirety of the base spine-csharp runtime is inside the "Runtime" folder, to be compiled in the same assembly as spine-unity so they can continue to share internal members.
* **Spine `.asmdef` files are now deactivated (using `.txt` extension) by default** This prevents problems when updating Spine through unitypackages, overwriting the Timeline reference entry in `spine-unity.asmdef` (added automatically when enabling Unity 2019 Timeline support, see `Timeline Support for Unity 2019`), causing compile errors. In case you want to enable the `.asmdef` files, rename the files:
`Spine/Runtime/spine-unity.txt` to `Spine/Runtime/spine-unity.asmdef` and
`Spine/Editor/spine-unity-editor.txt` to `Spine/Editor/spine-unity-editor.asmdef`.
* **SkeletonAnimator is now SkeletonMecanim** The Spine-Unity Mecanim-driven component `SkeletonAnimator` has been renamed `SkeletonMecanim` to make it more autocomplete-friendly and more obvious at human-glance. The .meta files and guids should remain intact so existing projects and prefabs should not break. However, user code needs to be updated to use `SkeletonMecanim`.
* **SpineAtlasAsset** The existing `AtlasAsset` type has been renamed to `SpineAtlasAsset` to signify that it specifically uses a Spine/libGDX atlas as its source. Serialization should be intact but user code will need to be updated to refer to existing atlases as `SpineAtlasAsset`.
* **AtlasAssetBase** `SpineAtlasAsset` now has an abstract base class called `SpineAtlasAsset`. This is the base class to derive when using alternate atlas sources. Existing SkeletonDataAsset field "atlasAssets" now have the "AtlasAssetBase" type. Serialization should be intact, but user code will need to be updated to refer to the atlas assets accordingly.
* This change is in preparation for alternate atlas options such as Unity's SpriteAtlas.
* **Optional Straight Alpha for shaders** Spine-Unity's included Unity shaders now have a `_STRAIGHT_ALPHA_INPUT` shader_feature, toggled as a checkbox in the Material's inspector. This allows the Material to use a non-premultiplied alpha/straight alpha input texture.
* The following shaders now have the "Straight Alpha Texture" checkbox when used on a material:
* `Spine/Skeleton`
* `Spine/Skeleton Tint Black`
* `Spine/Skeleton Lit`
* `Spine/Skeleton Tint`
* `Spine/Skeleton Fill`
* `Spine/SkeletonGraphic (Premultiply Alpha)` was renamed to `Spine/SkeletonGraphic`
* `Spine/SkeletonGraphic Tint Black (Premultiply Alpha)` was renamed to `Spine/SkeletonGraphic Tint Black`
* `Spine/Skeleton PMA Multiply`
* `Spine/Skeleton PMA Screen`
* Dedicated straight alpha shaders were removed from the runtime.
* `Spine/Straight Alpha/Skeleton Fill`
* `Spine/Straight Alpha/Skeleton Tint`
* **Detection of Incorrect Texture Settings** Especially when atlas textures are exported with setting `Premultiply alpha` enabled, it is important to configure Unity's texture import settings correctly. By default, you will now receive warnings where texture settings are expected to cause incorrect rendering.
* The following rules apply:
* `sRGB (Color Texture)` shall be disabled when `Generate Mip Maps` is enabled, otherwise you will receive white border outlines.
* `Alpha Is Transparency` shall be disabled on `Premultiply alpha` textures, otherwise you will receive light ghosting artifacts in transparent areas.
* These warnings can be disabled in `Edit - Preferences - Spine`.
* **Sprite Mask Support for all Included Shaders** The `Skeleton Animation` and `Skeleton Mecanim` components now provide an additional `Mask Interaction` field in the Inspector, covering identical functionality as Unity's built in `Sprite Renderer` component:
* `Mask Interaction` modes:
* `None` - The sprite will not interact with the masking system. Default behavior.
* `Visible Inside Mask` - The sprite will be visible only in areas where a mask is present.
* `Visible Outside Mask` - The sprite will be visible only in areas where no mask is present.
* `Automatically Generated Materials` When switching `Mask Interaction` modes in the Inspector outside of Play mode, the required additional material assets are generated for the respective `Stencil Compare` parameters - with file suffixes `'_InsideMask'` and `'_OutsideMask'`, placed in the same folder as the original materials. By default all generated materials are kept as references by the `Skeleton Animation` component for switching at runtime.
These materials can be managed and optimized via the `SkeletonAnimation`'s `Advanced` section:
* Using the `Clear` button you can clear the reference to unneeded materials,
* Using the `Delete` button the respective assets are deleted as well as references cleared. Note that other `Skeleton Animation` GameObjects might still reference the materials, so use with caution!
* With the `Set` button you can again assign a link to the respective materials to prepare them for runtime use. If the materials were not present or have been deleted, they are generated again based on the default materials.
* When switching `Mask Interaction` mode at runtime, the previously prepared materials are switched active automatically. When the respective materials have not been prepared, material copies of the default materials are created on the fly. Note that these materials are not shared between similar `Skeleton Animation` GameObjects, so it is recommended to use the generated material assets where possible.
* **Every shader now exposes the `Stencil Compare` parameter** for further customization. This way you have maximum flexibility to use custom mechanisms to switch materials at runtime if you should ever need more than the three materials generated by `Skeleton Animation`'s `Mask Interaction` parameter. Reference `Stencil Compare` values are:
* `CompareFunction.Disabled` for `Mask Interaction - None`
* `CompareFunction.LessEqual` for `Mask Interaction - Visible Inside Mask`
* `CompareFunction.Greater` for `Mask Interaction - Visible Outside Mask`
* **RectMask2D Support for SkeletonGraphic** Both `SkeletonGraphic` shaders '`Spine/SkeletonGraphic`' and '`Spine/SkeletonGraphic Tint Black`' now respect masking areas defined via Unity's `RectMask2D` component.
* **Timeline Support for Unity 2019** using the existing Timeline components. By default, all Spine Timeline components are deactivated in Unity 2019 and **can be activated via the Spine Preferences menu**. This step became necessary because in Unity 2019, Timeline has been moved to a separate Package and is no longer included in the Unity core. Please visit `Edit - Preferences - Spine` and at `Timeline Package Support` hit `Enable` to automatically perform all necessary steps to activate the Timeline components.
This will automatically:
1. download the Unity Timeline package
2. activate the Spine Timeline components by setting the compile definition `SPINE_TIMELINE_PACKAGE_DOWNLOADED` for all platforms
3. modify the `spine-unity.asmdef` file by adding the reference to the Unity Timeline library.
* Added `Create 2D Hinge Chain` functionality at `SkeletonUtilityBone` inspector, previously only `Create 3D Hinge Chain` was available.
### XNA/MonoGame
* Added support for any `Effect` to be used by `SkeletonRenderer`
* Added support for `IVertexEffect` to modify vertices of skeletons on the CPU. `IVertexEffect` instances can be set on the `SkeletonRenderer`. See example project.
* Added `SkeletonDebugRenderer`
* Made `MeshBatcher` of SkeletonRenderer accessible via a getter. Allows user to batch their own geometry together with skeleton meshes for maximum batching instead of using XNA SpriteBatcher.
## Java
* **Breaking changes**
* Skeleton attachments: Moved update of attached skeleton out of libGDX `SkeletonRenderer`, added overloaded method `Skeleton#updateWorldTransform(Bone)`, used for `SkeletonAttachment`. You now MUST call this new method with the bone of the parent skeleton to which the child skeleton is attached. See `SkeletonAttachmentTest` for and example.
* The completion event will fire for looped 0 duration animations every frame.
* `MixPose` is now called `MixBlend`.
* Skeleton `flipX/flipY` has been replaced with `scaleX/scaleY`. This cleans up applying transforms and is more powerful. Allows scaling a whole skeleton which has bones that disallow scale inheritance
* Mix time is no longer affected by `TrackEntry#timeScale`. See https://github.com/EsotericSoftware/spine-runtimes/issues/1194
* **Additions**
* Added `EventData#audioPath` field. This field contains the file name of the audio file used for the event.
* Added convenience method to add all attachments from one skin to another, see https://github.com/EsotericSoftware/spine-runtimes/commit/a0b7bb6c445efdfac12b0cdee2057afa3eff3ead
* Added additive animation blending. When playing back multiple animations on different tracks, where each animation modifies the same skeleton property, the results of tracks with lower indices are discarded, and only the result from the track with the highest index is used. With animation blending, the results of all tracks are mixed together. This allows effects like mixing multiple facial expressions (angry, happy, sad) with percentage mixes. By default the old behaviour is retained (results from lower tracks are discarded). To enable additive blending across animation tracks, call `TrackEntry#setMixBlend(MixBlend.add)` on each track. To specify the blend percentage, set `TrackEntry#alpha`. See http://esotericsoftware.com/forum/morph-target-track-animation-mix-mode-9459 for a discussion.
* Support for stretchy IK
* Support for audio events, see `audioPath`, `volume` and `balance` fields on event (data).
* `TrackEntry` has an additional field called `holdPrevious`. It can be used to counter act a limitation of `AnimationState` resulting in "dipping" of parts of the animation. For a full discussion of the problem and the solution we've implemented, see this [forum thread](http://esotericsoftware.com/forum/Probably-Easy-Animation-mixing-with-multiple-tracks-10682?p=48130&hilit=holdprevious#p48130).
### libGDX
* Added `VertexEffect` interface, instances of which can be set on `SkeletonRenderer`. Allows to modify vertices before submitting them to GPU. See `SwirlEffect`, `JitterEffect` and `VertexEffectTest`.
* Added improved tint-black shader.
* Improved performance by avoiding batch flush when not switching between normal and additive rendering with PMA
* Improvements to skeleton viewer.
* `TwoColorPolygonBatch` implements the `Batch` interface, allowing to the be used with other libGDX classes that require a batcher for drawing, potentially improving performance. See https://github.com/EsotericSoftware/spine-runtimes/commit/a46b3d1d0c135d51f9bef9ca17a5f8e5dda69927
* Added `SkeletonDrawable` to render skeletons in scene2d UI https://github.com/EsotericSoftware/spine-runtimes/commit/b93686c185e2c9d5466969a8e07eee573ebe4b97
## Lua
* **Breaking changes**
* The completion event will fire for looped 0 duration animations every frame.
* Skeleton `flipX/flipY` has been replaced with `scaleX/scaleY`. This cleans up applying transforms and is more powerful. Allows scaling a whole skeleton which has bones that disallow scale inheritance
* Mix time is no longer affected by `TrackEntry#timeScale`. See https://github.com/EsotericSoftware/spine-runtimes/issues/1194
* **Additions**
* Added `JitterEffect` and `SwirlEffect` and support for vertex effects in Corona and Love
* Added additive animation blending. When playing back multiple animations on different tracks, where each animation modifies the same skeleton property, the results of tracks with lower indices are discarded, and only the result from the track with the highest index is used. With animation blending, the results of all tracks are mixed together. This allows effects like mixing multiple facial expressions (angry, happy, sad) with percentage mixes. By default the old behaviour is retained (results from lower tracks are discarded). To enable additive blending across animation tracks, call `TrackEntry:setMixBlend(MixBlend.add)` on each track. To specify the blend percentage, set `TrackEntry.alpha`. See http://esotericsoftware.com/forum/morph-target-track-animation-mix-mode-9459 for a discussion.
* Support for stretchy IK
* Support for audio events, see `audioPath`, `volume` and `balance` fields on event (data).
* `TrackEntry` has an additional field called `holdPrevious`. It can be used to counter act a limitation of `AnimationState` resulting in "dipping" of parts of the animation. For a full discussion of the problem and the solution we've implemented, see this [forum thread](http://esotericsoftware.com/forum/Probably-Easy-Animation-mixing-with-multiple-tracks-10682?p=48130&hilit=holdprevious#p48130).
### Love2D
* Added support for vertex effects. Set an implementation like "JitterEffect" on `Skeleton.vertexEffect`. See `main.lua` for an example.
### Corona
* Added support for vertex effects. Set an implementation like "JitterEffect" on `SkeletonRenderer.vertexEffect`. See `main.lua` for an example
## Typescript/Javascript
* **Breaking changes**
* The completion event will fire for looped 0 duration animations every frame.
* Skeleton `flipX/flipY` has been replaced with `scaleX/scaleY`. This cleans up applying transforms and is more powerful. Allows scaling a whole skeleton which has bones that disallow scale inheritance
* Mix time is no longer affected by `TrackEntry#timeScale`. See https://github.com/EsotericSoftware/spine-runtimes/issues/1194
* **Additions**
* Added `AssetManager.loadTextureAtlas`. Instead of loading the `.atlas` and corresponding image files manually, you can simply specify the location of the `.atlas` file and AssetManager will load the atlas and all its images automatically. `AssetManager.get("atlasname.atlas")` will then return an instance of `spine.TextureAtlas`.
* Added additive animation blending. When playing back multiple animations on different tracks, where each animation modifies the same skeleton property, the results of tracks with lower indices are discarded, and only the result from the track with the highest index is used. With animation blending, the results of all tracks are mixed together. This allows effects like mixing multiple facial expressions (angry, happy, sad) with percentage mixes. By default the old behaviour is retained (results from lower tracks are discarded). To enable additive blending across animation tracks, call `TrackEntry#setMixBlend(MixBlend.add)` on each track. To specify the blend percentage, set `TrackEntry#alpha`. See http://esotericsoftware.com/forum/morph-target-track-animation-mix-mode-9459 for a discussion. See https://github.com/EsotericSoftware/spine-runtimes/blob/f045d221836fa56191ccda73dd42ae884d4731b8/spine-ts/webgl/tests/test-additive-animation-blending.html for an example.
* Added work-around for iOS WebKit JIT bug, see https://github.com/EsotericSoftware/spine-runtimes/commit/c28bbebf804980f55cdd773fed9ff145e0e7e76c
* Support for stretchy IK
* Support for audio events, see `audioPath`, `volume` and `balance` fields on event (data).
* `TrackEntry` has an additional field called `holdPrevious`. It can be used to counter act a limitation of `AnimationState` resulting in "dipping" of parts of the animation. For a full discussion of the problem and the solution we've implemented, see this [forum thread](http://esotericsoftware.com/forum/Probably-Easy-Animation-mixing-with-multiple-tracks-10682?p=48130&hilit=holdprevious#p48130).
* Added `AssetManager#setRawDataURI(path, data)`. Allows to set raw data URIs for a specific path, which in turn enables embedding assets into JavaScript/HTML.
### WebGL backend
* Added `VertexEffect` interface, instances of which can be set on `SkeletonRenderer`. Allows to modify vertices before submitting them to GPU. See `SwirlEffect`, `JitterEffect`, and the example which allows to set effects.
* Added `slotRangeStart` and `slotRangeEnd` parameters to `SkeletonRenderer#draw` and `SceneRenderer#drawSkeleton`. This allows you to render only a range of slots in the draw order. See `spine-ts/webgl/tests/test-slot-range.html` for an example.
* Added improved tint-black shader.
* Added `SceneRenderer#drawTextureUV()`, allowing to draw a texture with manually specified texture coordinates.
* Exposed all renderers in `SceneRenderer`.
### Canvas backend
* Added support for shearing and non-uniform scaling inherited from parent bones.
* Added support for alpha tinting.
### Three.js backend
* Added `VertexEffect` interface, instances of which can be set on `SkeletonMesh`. Allows to modify vertices before submitting them to GPU. See `SwirlEffect`, `JitterEffect`.
* Added support for multi-page atlases
### Widget backend
* Added fields `atlasContent`, `atlasPagesContent`, and `jsonContent` to `WidgetConfiguration` allowing you to directly pass the contents of the `.atlas`, atlas page `.png` files, and the `.json` file without having to do a request. See `README.md` and the example for details.
* `SpineWidget.setAnimation()` now takes an additional optional parameter for callbacks when animations are completed/interrupted/etc.
# 3.6
## AS3
* **Breaking changes**
* Removed `Bone.worldToLocalRotationX` and `Bone.worldToLocalRotationY`. Replaced by `Bone.worldToLocalRotation` (rotation given relative to x-axis, counter-clockwise, in degrees).
* Made `Bone` fields `_a`, `_b`, `_c`, `_d`, `_worldX` and `_worldY` public, removed underscore prefix.
* Removed `VertexAttachment.computeWorldVertices` overload, changed `VertexAttachment.computeWorldVertices2` to `VertexAttachment.computeWorldVertices`, added `stride` parameter.
* Removed `RegionAttachment.vertices` field. The vertices array is provided to `RegionAttachment.computeWorldVertices` by the API user now.
* Removed `RegionAttachment.updateWorldVertices`, added `RegionAttachment.computeWorldVertices`. The new method now computes the x/y positions of the 4 vertices of the corner and places them in the provided `worldVertices` array, starting at `offset`, then moving by `stride` array elements when advancing to the next vertex. This allows to directly compose the vertex buffer and avoids a copy. The computation of the full vertices, including vertex colors and texture coordinates, is now done by the backend's respective renderer.
* Replaced `r`, `g`, `b`, `a` fields with instances of new `Color` class in `RegionAttachment`, `MeshAttachment`, `Skeleton`, `SkeletonData`, `Slot` and `SlotData`.
* The completion event will fire for looped 0 duration animations every frame.
* **Additions**
* Added `Skeleton.getBounds` from reference implementation.
* Added support for local and relative transform constraint calculation, including additional fields in `TransformConstraintData`
* Added `Bone.localToWorldRotation`(rotation given relative to x-axis, counter-clockwise, in degrees).
* Added two color tinting support, including `TwoColorTimeline` and additional fields on `Slot` and `SlotData`.
* Added `PointAttachment`, additional method `newPointAttachment` in `AttachmentLoader` interface.
* Added `ClippingAttachment`, additional method `newClippingAttachment` in `AttachmentLoader` interface.
* `AnimationState#apply` returns boolean indicating if any timeline was applied or not.
* `Animation#apply` and `Timeline#apply`` now take enums `MixPose` and `MixDirection` instead of booleans
* Added `VertexEffect` and implementations `JitterEffect` and `SwirlEffect`. Allows you to modify vertices before they are submitted for drawing. See Starling changes.
### Starling
* Fixed renderer to work with 3.6 changes.
* Added support for two color tinting.
* Added support for clipping.
* Added support for rotated regions in texture atlas loaded via StarlingAtlasAttachmentLoader.
* Added support for vertex effects. See `RaptorExample.as`
* Added 'getTexture()' method to 'StarlingTextureAtlasAttachmentLoader'
* Breaking change: if a skeleton requires two color tinting, you have to enable it via `SkeletonSprite.twoColorTint = true`. In this case the skeleton will use the `TwoColorMeshStyle`, which internally uses a different vertex layout and shader. This means that skeletons with two color tinting enabled will break batching and hence increase the number of draw calls in your app.
## C
* **Breaking changes**
* `spVertexAttachment_computeWorldVertices` and `spRegionAttachment_computeWorldVerticeS` now take new parameters to make it possible to directly output the calculated vertex positions to a vertex buffer. Removes the need for additional copies in the backends' respective renderers.
* Removed `spBoundingBoxAttachment_computeWorldVertices`, superseded by `spVertexAttachment_computeWorldVertices`.
* Removed `spPathAttachment_computeWorldVertices` and `spPathAttachment_computeWorldVertices1`, superseded by `spVertexAttachment_computeWorldVertices`.
* Removed `sp_MeshAttachment_computeWorldVertices`, superseded by `spVertexAttachment_computeWorldVertices`.
* Removed `spBone_worldToLocalRotationX` and `spBone_worldToLocalRotationY`. Replaced by `spBone_worldToLocalRotation` (rotation given relative to x-axis, counter-clockwise, in degrees).
* Replaced `r`, `g`, `b`, `a` fields with instances of new `spColor` struct in `spRegionAttachment`, `spMeshAttachment`, `spSkeleton`, `spSkeletonData`, `spSlot` and `spSlotData`.
* Removed `spVertexIndex`from public API.
* Listeners on `spAnimationState` or `spTrackEntry` will now be also called in case a track entry is disposed as part of dispoing the `spAnimationState`.
* The completion event will fire for looped 0 duration animations every frame.
* **Additions**
* Added support for local and relative transform constraint calculation, including additional fields in `spTransformConstraintData`.
* Added `spPointAttachment`, additional method `spAtlasAttachmentLoadeR_newPointAttachment`.
* Added support for local and relative transform constraint calculation, including additional fields in `TransformConstraintData`
* Added `spBone_localToWorldRotation`(rotation given relative to x-axis, counter-clockwise, in degrees).
* Added two color tinting support, including `spTwoColorTimeline` and additional fields on `spSlot` and `spSlotData`.
* Added `userData` field to `spTrackEntry`, so users can expose data in `spAnimationState` callbacks.
* Modified kvec.h used by SkeletonBinary.c to use Spine's MALLOC/FREE macros. That way there's only one place to inject custom allocators ([extension.h](https://github.com/EsotericSoftware/spine-runtimes/blob/master/spine-c/spine-c/include/spine/extension.h)) [commit](https://github.com/EsotericSoftware/spine-runtimes/commit/c2cfbc6cb8709daa082726222d558188d75a004f)
* Added macros to define typed dynamic arrays, see `Array.h/.c`
* Added `spClippingAttachment` and respective enum.
* Added `spSkeletonClipper` and `spTriangulator`, used to implement software clipping of attachments.
* `AnimationState#apply` returns boolean indicating if any timeline was applied or not.
* `Animation#apply` and `Timeline#apply`` now take enums `MixPose` and `MixDirection` instead of booleans
* Added `spVertexEffect` and corresponding implementations `spJitterVertexEffect` and `spSwirlVertexEffect`. Create/dispose through the corresponding `spXXXVertexEffect_create()/dispose()` functions. Set on framework/engine specific renderer. See changes for spine-c based frameworks/engines below.
* Functions in `extension.h` are not prefixed with `_sp` instead of just `_` to avoid interference with other libraries.
* Introduced `SP_API` macro. Every spine-c function is prefixed with this macro. By default, it is an empty string. Can be used to markup spine-c functions with e.g. ``__declspec` when compiling to a dll or linking to that dll.
### Cocos2d-X
* Fixed renderer to work with 3.6 changes
* Optimized rendering by removing all per-frame allocation in `SkeletonRenderer`, resulting in 15% performance increase for large numbers of skeletons being rendered per frame.
* Added support for two color tinting. Tinting is enabled/disabled per `SkeletonRenderer`/`SkeletonAnimation` instance. Use `SkeletonRenderer::setTwoColorTint()`. Note that two color tinting requires the use of a non-standard shader and vertex format. This means that skeletons rendered with two color tinting will break batching. However, skeletons with two color tinting enabled and rendered after each other will be batched.
* Updated example to use Cocos2d-x 3.14.1.
* Added mesh debug rendering. Enable/Disable via `SkeletonRenderer::setDebugMeshesEnabled()`.
* Added support for clipping.
* SkeletonRenderer now combines the displayed color of the Node (cascaded from all parents) with the skeleton color for tinting.
* Added support for vertex effects. See `RaptorExample.cpp`.
* Added ETC1 alpha support, thanks @halx99! Does not work when two color tint is enabled.
* Added `spAtlasPage_setCustomTextureLoader()` which let's you do texture loading manually. Thanks @jareguo.
* Added `SkeletonRenderer:setSlotsRange()` and `SkeletonRenderer::createWithSkeleton()`. This allows you to split rendering of a skeleton up into multiple parts, and render other nodes in between. See `SkeletonRendererSeparatorExample.cpp` for an example.
### Cocos2d-Objc
* Fixed renderer to work with 3.6 changes
* Added support for two color tinting. Tinting is enabled/disabled per `SkeletonRenderer/SkeletonAnimation.twoColorTint = true`. Note that two color tinted skeletons do not batch with other nodes.
* Added support for clipping.
### SFML
* Fixed renderer to work with 3.6 changes. Sadly, two color tinting does not work, as the vertex format in SFML is fixed.
* Added support for clipping.
* Added support for vertex effects. See raptor example.
* Added premultiplied alpha support to `SkeletonDrawable`.
### Unreal Engine 4
* Fixed renderer to work with 3.6 changes
* Added new UPROPERTY to SpineSkeletonRendererComponent called `Color`. This allows to set the tint color of the skeleton in the editor, C++ and Blueprints. Under the hood, the `spSkeleton->color` will be set on every tick of the renderer component.
* Added support for clipping.
* Switched from built-in ProceduralMeshComponent to RuntimeMeshComponent by Koderz (https://github.com/Koderz/UE4RuntimeMeshComponent, MIT). Needed for more flexibility regarding vertex format, should not have an impact on existing code/assets. You need to copy the RuntimeMeshComponentPlugin from our repository in `spine-ue4\Plugins\` to your project as well!
* Added support for two color tinting. All base materials, e.g. SpineUnlitNormalMaterial, now do proper two color tinting. No material parameters have changed.
* Updated to Unreal Engine 4.16.1. Note that 4.16 has a regression which will make it impossible to compile plain .c files!
* spine-c is now exposed from the plugin shared library on Windows via __declspec.
## C#
* **Breaking changes**
* `MeshAttachment.parentMesh` is now a private field to enforce using the `.ParentMesh` setter property in external code. The `MeshAttachment.ParentMesh` property is an appropriate replacement wherever `.parentMesh` was used.
* `Skeleton.GetBounds` takes a scratch array as input so it doesn't have to allocate a new array on each invocation itself. Reduces GC activity.
* Removed `Bone.WorldToLocalRotationX` and `Bone.WorldToLocalRotationY`. Replaced by `Bone.WorldToLocalRotation` (rotation given relative to x-axis, counter-clockwise, in degrees).
* Added `stride` parameter to `VertexAttachment.ComputeWorldVertices`.
* Removed `RegionAttachment.Vertices` field. The vertices array is provided to `RegionAttachment.ComputeWorldVertices` by the API user now.
* Removed `RegionAttachment.UpdateWorldVertices`, added `RegionAttachment.ComputeWorldVertices`. The new method now computes the x/y positions of the 4 vertices of the corner and places them in the provided `worldVertices` array, starting at `offset`, then moving by `stride` array elements when advancing to the next vertex. This allows to directly compose the vertex buffer and avoids a copy. The computation of the full vertices, including vertex colors and texture coordinates, is now done by the backend's respective renderer.
* The completion event will fire for looped 0 duration animations every frame.
* **Additions**
* Added support for local and relative transform constraint calculation, including additional fields in `TransformConstraintData`
* Added `Bone.localToWorldRotation`(rotation given relative to x-axis, counter-clockwise, in degrees).
* Added two color tinting support, including `TwoColorTimeline` and additional fields on `Slot` and `SlotData`.
* Added `PointAttachment`, additional method `NewPointAttachment` in `AttachmentLoader` interface.
* Added `ClippingAttachment`, additional method `NewClippingAttachment` in `AttachmentLoader` interface.
* Added `SkeletonClipper` and `Triangulator`, used to implement software clipping of attachments.
* `AnimationState.Apply` returns a bool indicating if any timeline was applied or not.
* `Animation.Apply` and `Timeline.Apply`` now take enums `MixPose` and `MixDirection` instead of bools.
### Unity
* Refactored renderer to work with new 3.6 features.
* **Two color tinting** is currently supported via extra UV2 and UV3 mesh vertex streams. To use Two color tinting, you need to:
* switch on "Tint Black" under "Advanced...",
* use the new `Spine/Skeleton Tint Black` shader, or your own shader that treats the UV2 and UV3 streams similarly.
* Additionally, for SkeletonGraphic, you can use `Spine/SkeletonGraphic Tint Black` (or the bundled SkeletonGraphicTintBlack material) or your own shader that uses UV2 and UV3 streams similarly. **Additional Shader Channels** TexCoord1 and TexCoord2 will need to be enabled from the Canvas component's inspector. These correspond to UV2 and UV3.
* **Clipping** is now supported. Caution: The SkeletonAnimation switches to slightly slower mesh generation code when clipping so limit your use of `ClippingAttachment`s when using on large numbers of skeletons.
* **SkeletonRenderer.initialFlip** Spine components such as SkeletonRenderer, SkeletonAnimation, SkeletonAnimator now has `initialFlipX` and `initialFlipY` fields which are also visible in the inspector under "Advanced...". It will allow you to set and preview a starting flip value for your skeleton component. This is applied immediately when the internal skeleton object is instantiated.
* **[SpineAttribute] Improvements**
* **Icons have been added to SpineAttributeDrawers**. This should make your default inspectors easier to understand at a glance.
* **Added Constraint Attributes** You can now use `[SpineIkConstraint]` `[SpineTransformConstraint]` `[SpinePathConstraint]`
* **SpineAttribute dataField** parameter can also now detect sibling fields within arrays and serializable structs/classes.
* **[SpineAttribute(includeNone:false)]** SpineAttributes now have an `includeNone` optional parameter to specify if you want to include or exclude a none ("") value option in the dropdown menu. Default is `includeNone:true`.
* **[SpineAttachment(skinField:"mySkin")]** The SpineAttachment attribute now has a skinField optional parameter to limit the dropdown items to attachments in a specific skin instead of the just default skin or all the skins in SkeletonData.
* **SkeletonDebugWindow**. Debugging tools have been moved from the SkeletonAnimation and SkeletonUtility component inspectors into its own utility window. You can access "Skeleton Debug" under the `Advanced...` foldout in the SkeletonAnimation inspector, or in SkeletonAnimation's right-click/context menu.
* **Skeleton Baking Window** The old Skeleton Baking feature is also now accessible through the SkeletonDataAsset's right-click/context menu.
* **AttachmentTools source material**. `AttachmentTools` methods can now accept a `sourceMaterial` argument to copy material properties from.
* **AttachmentTools Skin Extensions**. Using AttachmentTools, you can now add entries by slot name by also providing a skeleton argument. Also `Append(Skin)`, `RemoveAttachment` and `Clear` have been added.
* **BoneFollower and SkeletonUtilityBone Add RigidBody Button**. The BoneFollower and SkeletonUtilityBone component inspectors will now offer to add a `Rigidbody` or `Rigidbody2D` if it detects a collider of the appropriate type. Having a rigidbody on a moving transform with a collider fits better with the Unity physics systems and prevents excess calculations. It will not detect colliders on child objects so you have to add Rigidbody components manually accordingly.
* **SkeletonRenderer.OnPostProcessVertices** is a new callback that gives you a reference to the MeshGenerator after it has generated a mesh from the current skeleton pose. You can access `meshGenerator.VertexBuffer` or `meshGenerator.ColorBuffer` to modify these before they get pushed into the UnityEngine.Mesh for rendering. This can be useful for non-shader vertex effects.
* **Examples**
* **Examples now use properties**. The code in the example scripts have been switched over to using properties instead of fields to encourage their use for consistency. This is in anticipation of both users who want to move the Spine folders to the Unity Plugins folder (compiled as a different assembly), and of Unity 2017's ability to manually define different assemblies for shorter compilation times.
* **Mix And Match**. The mix-and-match example scene, code and data have been updated to reflect the current recommended setup for animation-compatible custom equip systems The underlying API has changed since 3.5 and the new API calls in MixAndMatch.cs is recommended. Documentation is in progress.
* **Sample Components**. `AtasRegionAttacher` and `SpriteAttacher` are now part of `Sample Components`, to reflect that they are meant to be used as sample code rather than production. A few other sample components have also been added. New imports of the unitypackage Examples folder will see a "Legacy" folder comprised of old sample components that no longer contain the most up-to-date and recommended workflows, but are kept in case old setups used them for production.
* **Spine folder**. In the unitypackage, the "spine-csharp" and "spine-unity" folders are now inside a "Spine" folder. This change will only affect fresh imports. Importing the unitypackage to update Spine-Unity in your existing project will update the appropriate files however you chose to arrange them, as long as the meta files are intact.
* **Breaking changes**
* The Sprite shaders module was updated to the latest version from the [source](https://github.com/traggett/UnitySpriteShaders/commits/master). Some changes were made to the underlying keyword structure. You may need to review the settings of your lit materials. Particularly, your Fixed Normals settings.
* The `Spine/Skeleton Lit` shader was switched over to non-fixed-function code. It now no longer requires mesh normals and has fixed normals at the shader level.
* The old MeshGenerator classes, interfaces and code in `Spine.Unity.MeshGeneration` are now deprecated. All mesh-generating components now share the class `Spine.Unity.MeshGenerator` defined in `SpineMesh.cs`. MeshGenerator is a serializable class.
* The `SkeletonRenderer.renderMeshes` optimization is currently non-functional.
* Old triangle-winding code has been removed from `SkeletonRenderer`. Please use shaders that have backface culling off.
* Render settings in `SkeletonGraphic` can now be accessed under `SkeletonGraphic.MeshGenerator.settings`. This is visible in the SkeletonGraphic inspector as `Advanced...`
* We will continue to bundle the unitypackage with the empty .cs files of deprecated classes until Spine 3.7 to ensure the upgrade process does not break.
* The [SpineAttachment(slotField:)] optional parameter found property value now acts as a Find(slotName) argument rather than Contains(slotName).
* `SkeletonAnimator` now uses a `SkeletonAnimator.MecanimTranslator` class to translate an Animator's Mecanim State Machine into skeleton poses. This makes code reuse possible for a Mecanim version of SkeletonGraphic.
* `SkeletonAnimator` `autoreset` and the `mixModes` array are now a part of SkeletonAnimator's MecanimTranslator `.Translator`. `autoReset` is set to true by default. Old prefabs and scene objects with Skeleton Animator may no longer have correct values set.
* Warnings and conditionals checking for specific Unity 5.2-and-below incompatibility have been removed.
## XNA/MonoGame
* Added support for clipping
* Removed `RegionBatcher` and `SkeletonRegionRenderer`, renamed `SkeletonMeshRenderer` to `SkeletonRenderer`
* Added support for two color tint. For it to work, you need to add the `SpineEffect.fx` file to your content project, then load it via `var effect = Content.Load<Effect>("SpineEffect");`, and set it on the `SkeletonRenderer`. See the example project for code.
* Added support for any `Effect` to be used by `SkeletonRenderer`
* Added support for `IVertexEffect` to modify vertices of skeletons on the CPU. `IVertexEffect` instances can be set on the `SkeletonRenderer`. See example project.
* Added `SkeletonDebugRenderer`
* Made `MeshBatcher` of SkeletonRenderer accessible via a getter. Allows user to batch their own geometry together with skeleton meshes for maximum batching instead of using XNA SpriteBatcher.
## Java
* **Breaking changes**
* `Skeleton.getBounds` takes a scratch array as input so it doesn't have to allocate a new array on each invocation itself. Reduces GC activity.
* Removed `Bone.worldToLocalRotationX` and `Bone.worldToLocalRotationY`. Replaced by `Bone.worldToLocalRotation` (rotation given relative to x-axis, counter-clockwise, in degrees).
* Added `stride` parameter to `VertexAttachment.computeWorldVertices`.
* Removed `RegionAttachment.vertices` field. The vertices array is provided to `RegionAttachment.computeWorldVertices` by the API user now.
* Removed `RegionAttachment.updateWorldVertices`, added `RegionAttachment.computeWorldVertices`. The new method now computes the x/y positions of the 4 vertices of the corner and places them in the provided `worldVertices` array, starting at `offset`, then moving by `stride` array elements when advancing to the next vertex. This allows to directly compose the vertex buffer and avoids a copy. The computation of the full vertices, including vertex colors and texture coordinates, is now done by the backend's respective renderer.
* Skeleton attachments: Moved update of attached skeleton out of libGDX `SkeletonRenderer`, added overloaded method `Skeleton#updateWorldTransform(Bone), used for `SkeletonAttachment`. You now MUST call this new method
with the bone of the parent skeleton to which the child skeleton is attached. See `SkeletonAttachmentTest` for and example.
* The completion event will fire for looped 0 duration animations every frame.
* **Additions**
* Added support for local and relative transform constraint calculation, including additional fields in `TransformConstraintData`
* Added `Bone.localToWorldRotation`(rotation given relative to x-axis, counter-clockwise, in degrees).
* Added two color tinting support, including `TwoColorTimeline` and additional fields on `Slot` and `SlotData`.
* Added `PointAttachment`, additional method `newPointAttachment` in `AttachmentLoader` interface.
* Added `ClippingAttachment`, additional method `newClippingAttachment` in `AttachmentLoader` interface.
* Added `SkeletonClipper` and `Triangulator`, used to implement software clipping of attachments.
* `AnimationState#apply` returns boolean indicating if any timeline was applied or not.
* `Animation#apply` and `Timeline#apply`` now take enums `MixPose` and `MixDirection` instead of booleans
### libGDX
* Fixed renderer to work with 3.6 changes
* Added support for two color tinting. Use the new `TwoColorPolygonBatch` together with `SkeletonRenderer`
* Added support for clipping. See `SkeletonClipper`. Used automatically by `SkeletonRenderer`. Does not work when using a `SpriteBatch` with `SkeletonRenderer`. Use `PolygonSpriteBatch` or `TwoColorPolygonBatch` instead.
* Added `VertexEffect` interface, instances of which can be set on `SkeletonRenderer`. Allows to modify vertices before submitting them to GPU. See `SwirlEffect`, `JitterEffect` and `VertexEffectTest`.
## Lua
* **Breaking changes**
* Removed `Bone:worldToLocalRotationX` and `Bone:worldToLocalRotationY`. Replaced by `Bone:worldToLocalRotation` (rotation given relative to x-axis, counter-clockwise, in degrees).
* `VertexAttachment:computeWorldVertices` now takes offsets and stride to allow compositing vertices directly in a vertex buffer to be send to the GPU. The compositing is now performed in the backends' respective renderers. This also affects the subclasses `MeshAttachment`, `BoundingBoxAttachment` and `PathAttachment`.
* Removed `RegionAttachment:updateWorldVertices`, added `RegionAttachment:computeWorldVertices`, which takes offsets and stride to allow compositing vertices directly in a vertex buffer to be send to the GPU. The compositing is now performed in the backends' respective renderers.
* Removed `MeshAttachment.worldVertices` field. Computation is now performed in each backends' respective renderer. The `uv` coordinates are now stored in `MeshAttachment.uvs`.
* Removed `RegionAttachment.vertices` field. Computation is now performed in each backends respective renderer. The `uv` coordinates for each vertex are now stored in the `RegionAttachment.uvs` field.
* The completion event will fire for looped 0 duration animations every frame.
* **Additions**
* Added `Bone:localToWorldRotation`(rotation given relative to x-axis, counter-clockwise, in degrees).
* Added two color tinting support, including `TwoColorTimeline` and additional fields on `Slot` and `SlotData`.
* Added `PointAttachment`, additional method `newPointAttachment` in `AttachmentLoader` interface.
* Added support for local and relative transform constraint calculation, including additional fields in `TransformConstraintData`
* Added `ClippingAttachment`, additional method `newClippingAttachment` in `AttachmentLoader` interface.
* Added `SkeletonClipper` and `Triangulator`, used to implement software clipping of attachments.
* `AnimationState#apply` returns boolean indicating if any timeline was applied or not.
* `Animation#apply` and `Timeline#apply`` now take enums `MixPose` and `MixDirection` instead of booleans
* Added `JitterEffect` and `SwirlEffect` and support for vertex effects in Corona and Love
### Love2D
* Fixed renderer to work with 3.6 changes
* Added support for two color tinting. Enable it via `SkeletonRenderer.new(true)`.
* Added clipping support.
* Added support for vertex effects. Set an implementation like "JitterEffect" on `Skeleton.vertexEffect`. See `main.lua` for an example.
### Corona
* Fixed renderer to work with 3.6 changes. Sadly, two color tinting is not supported, as Corona doesn't let us change the vertex format needed and its doesn't allow to modify shaders in the way needed for two color tinting
* Added clipping support.
* Added support for vertex effects. Set an implementation like "JitterEffect" on `SkeletonRenderer.vertexEffect`. See `main.lua` for an example
## Typescript/Javascript
* **Breaking changes**
* `Skeleton.getBounds` takes a scratch array as input so it doesn't have to allocate a new array on each invocation itself. Reduces GC activity.
* Removed `Bone.worldToLocalRotationX` and `Bone.worldToLocalRotationY`. Replaced by `Bone.worldToLocalRotation` (rotation given relative to x-axis, counter-clockwise, in degrees).
* Removed `VertexAttachment.computeWorldVertices` overload, changed `VertexAttachment.computeWorldVerticesWith` to `VertexAttachment.computeWorldVertices`, added `stride` parameter.
* Removed `RegionAttachment.vertices` field. The vertices array is provided to `RegionAttachment.computeWorldVertices` by the API user now.
* Removed `RegionAttachment.updateWorldVertices`, added `RegionAttachment.computeWorldVertices`. The new method now computes the x/y positions of the 4 vertices of the corner and places them in the provided `worldVertices` array, starting at `offset`, then moving by `stride` array elements when advancing to the next vertex. This allows to directly compose the vertex buffer and avoids a copy. The computation of the full vertices, including vertex colors and texture coordinates, is now done by the backend's respective renderer.
* The completion event will fire for looped 0 duration animations every frame.
* Removed the Spine Widget in favor of [Spine Web Player](https://esotericsoftware.com/spine-player).
* **Additions**
* Added support for local and relative transform constraint calculation, including additional fields in `TransformConstraintData`
* Added `Bone.localToWorldRotation`(rotation given relative to x-axis, counter-clockwise, in degrees).
* Added two color tinting support, including `TwoColorTimeline` and additional fields on `Slot` and `SlotData`.
* Added `PointAttachment`, additional method `newPointAttachment` in `AttachmentLoader` interface.
* Added `ClippingAttachment`, additional method `newClippingAttachment` in `AttachmentLoader` interface.
* Added `SkeletonClipper` and `Triangulator`, used to implement software clipping of attachments.
* `AnimationState#apply` returns boolean indicating if any timeline was applied or not.
* `Animation#apply` and `Timeline#apply`` now take enums `MixPose` and `MixDirection` instead of booleans
* Added `AssetManager.loadTextureAtlas`. Instead of loading the `.atlas` and corresponding image files manually, you can simply specify the location of the `.atlas` file and AssetManager will load the atlas and all its images automatically. `AssetManager.get("atlasname.atlas")` will then return an instance of `spine.TextureAtlas`.
* Added the [Spine Web Player](https://esotericsoftware.com/spine-player)
### WebGL backend
* Fixed WebGL context loss
* Added `Restorable` interface, implemented by any WebGL resource that needs restoration after a context loss. All WebGL resource classes (`Shader`, `Mesh`, `GLTexture`) implement this interface.
* Added `ManagedWebGLRenderingContext`. Handles setup of a `WebGLRenderingContext` given a canvas element and restoration of WebGL resources (`Shader`, `Mesh`, `GLTexture`) on WebGL context loss. WebGL resources register themselves with the `ManagedWebGLRenderingContext`. If the context is informed of a context loss and restoration, the registered WebGL resources' `restore()` method is called. The `restore()` method implementation on each resource type will recreate the GPU side objects.
* All classes that previously took a `WebGLRenderingContext` in the constructor now also allow a `ManagedWebGLRenderingContext`. This ensures existing applications do not break.
* To use automatic context restauration:
1. Create or fetch a canvas element from the DOM
2. Instantiate a `ManagedWebGLRenderingContext`, passing the canvas to the constructor. This will setup a `WebGLRenderingContext` internally and manage context loss/restoration.
3. Pass the `ManagedWebGLRenderingContext` to the constructors of classes that you previously passed a `WebGLRenderingContext` to (`AssetManager`, `GLTexture`, `Mesh`, `Shader`, `PolygonBatcher`, `SceneRenderer`, `ShapeRenderer`, `SkeletonRenderer`, `SkeletonDebugRenderer`).
* Fixed renderer to work with 3.6 changes.
* Added support for two color tinting.
* Improved performance by using `DYNAMIC_DRAW` for vertex buffer objects and fixing bug that copied to much data to the GPU each frame in `PolygonBatcher`/`Mesh`.
* Added two color tinting support, enabled by default. You can disable it via the constructors of `SceneRenderer`, `SkeletonRenderer`and `PolygonBatcher`. Note that you will need to use a shader created via `Shader.newTwoColoredTexturedShader` shader with `SkeletonRenderer` and `PolygonBatcher` if two color tinting is enabled.
* Added clipping support
* Added `VertexEffect` interface, instances of which can be set on `SkeletonRenderer`. Allows to modify vertices before submitting them to GPU. See `SwirlEffect`, `JitterEffect`, and the example which allows to set effects.
* Added `slotRangeStart` and `slotRangeEnd` parameters to `SkeletonRenderer#draw` and `SceneRenderer#drawSkeleton`. This allows you to render only a range of slots in the draw order. See `spine-ts/webgl/tests/test-slot-range.html` for an example.
### Canvas backend
* Fixed renderer to work for 3.6 changes. Sadly, we can't support two color tinting via the Canvas API.
* Added support for shearing and non-uniform scaling inherited from parent bones.
* Added support for alpha tinting.
### Three.js backend
* Fixed renderer to work with 3.6 changes. Two color tinting is not supported.
* Added clipping support
* Added `VertexEffect` interface, instances of which can be set on `SkeletonMesh`. Allows to modify vertices before submitting them to GPU. See `SwirlEffect`, `JitterEffect`.
* Added support for multi-page atlases
### Widget backend
* Fixed WebGL context loss (see WebGL backend changes). Enabled automatically.
* Fixed renderer to work for 3.6 changes. Supports two color tinting & clipping (see WebGL backend changes for details).
* Added fields `atlasContent`, `atlasPagesContent`, and `jsonContent` to `WidgetConfiguration` allowing you to directly pass the contents of the `.atlas`, atlas page `.png` files, and the `.json` file without having to do a request. See `README.md` and the example for details.
* `SpineWidget.setAnimation()` now takes an additional optional parameter for callbacks when animations are completed/interrupted/etc.
Loaded 100 of 1340 files, more files were not shown because too many files have changed in this diff. Show more