Spine 骨骼模型加载
Ark-Pets 使用 Spine 运行时(com.esotericsoftware.spine)来驱动桌宠的骨骼动画,而 SkeletonLoader(位于 core/src/cn/harryh/arkpets/assets/)是骨骼文件进入渲染管线前的统一入口:它在真正解析骨骼之前,先读取文件头部元数据、识别 JSON/二进制双格式,并对有缺陷的 .skel 文件执行透明修复,最后将纹理图集与缩放系数交给 Spine 运行时生成 SkeletonData。
目的与范围(Purpose and Scope)
本页覆盖 Spine 骨骼模型加载 这一能力的完整实现,包括:
SkeletonLoader的头部元数据解析逻辑(JSON 与二进制两种格式);- 骨骼文件缺陷检测(
needFix())与修复流程(fixed()、fixString()); - 骨骼数据与纹理图集的装配(
loadSkeletonDataWith()的两个重载,含 mipmapping 处理); - 相关的容量上限、字符编码等边界与失败模式。
不在本页范围(属于兄弟页面的主题):
- 模型列表的扫描与元数据组织(
ModelItem/ModelItemGroup/ModelsDataset)——见「模型资产管理」相关页面; - 动画剪辑与行为编排(
AnimClip/Behavior/StochasticMatrix等)——见「动画与行为」相关页面; - 基于骨骼的渲染绘制(
SpineRenderPass)——见渲染相关页面。
概述(Overview)
一个 Ark-Pets 模型通常由三部分组成:骨骼文件(.json 或 .skel)、纹理图集(.atlas 及其贴图)、以及模型元数据。Spine 官方运行时提供了 SkeletonJson 与 SkeletonBinary 两个解析器,但直接把用户提供的文件喂给运行时会遇到几个现实问题:
- 需要预读元数据:在加载完整骨骼之前,Ark-Pets 需要先知道骨骼的版本号、
hash、包围盒、fps等头部信息(例如用于模型列表展示与兼容性判断),而这些信息只有解析头部才能获得。 - 社区导出的骨骼文件存在缺陷:某些第三方导出工具生成的二进制骨骼会携带尾部空白字符的字符串(Issue #150),或包含无法在默认字符集下无损往返的字符串(Issue #160),后者会让 Spine 运行时(以平台默认编码读取字节)解析出乱码甚至崩溃。
- 需要对纹理图集做统一缩放与 mipmapping 控制:桌宠渲染时使用
scale缩放骨骼,且在小尺寸渲染下需要强制 mipmaps 以避免闪烁。
SkeletonLoader 正是为解决这些问题而存在的薄封装层。它读取文件头部,把缺陷文件修复为临时文件,再委托 Spine 官方运行时完成真正的 SkeletonData 解析。
架构(Architecture)
层次关系说明:
- 头部解析:JSON 格式走 fastjson2(
JSON.parseObject后读取skeleton节点);二进制格式则由SkeletonLoader自己按 Spine 二进制布局逐字段读取(hash、version、x/y/width/height、nonEssential、可选的fps/images/audio、字符串池)。 - 修复层:
needFix()只针对二进制格式检查字符串池;fixed()生成一个新的临时骨骼文件(重写头部 + 修复后的字符串池 + 原文件剩余字节),并返回基于该临时文件的新SkeletonLoader实例。 - 装配层:
loadSkeletonDataWith()把图集与缩放系数交给 Spine 运行时的SkeletonJson/SkeletonBinary,得到SkeletonData;FileHandle 重载版本还会在需要时强制开启 mipmaps。
主内容:实现详解
头部元数据与公开字段
SkeletonLoader 构造函数接收一个 libGDX FileHandle,并立即完成头部解析。解析结果全部落在不可变的 public final 字段上,供下游(模型列表、兼容性检查等)直接读取:
1public class SkeletonLoader {
2 protected final FileHandle file;
3 protected final boolean isJson;
4 protected final long headerSize;
5
6 public final String hash;
7 public final String version;
8 public final float x, y, width, height;
9 public final boolean nonEssential;
10 public final float fps;
11 public final String images_path, audio_path;
12 public final List<String> strings;
13
14 protected static final int MAX_SKELETON_FILE_SIZE = 64 << 20;Source: SkeletonLoader.java
设计意图:把「头部信息」做成不可变快照,调用方在构造实例之后即可获得所有元数据,无需重复 I/O。MAX_SKELETON_FILE_SIZE(64 << 20,即 64 MiB)是防御性上限,防止异常巨大的骨骼文件把整个文件读入内存。
构造函数在开头即做尺寸校验,随后按格式分流:
1public SkeletonLoader(FileHandle file) throws IOException {
2 this.file = file;
3 if (file.length() > MAX_SKELETON_FILE_SIZE) {
4 throw new IOException("Skeleton file is to large");
5 }
6 isJson = isJson(file);
7 // ...头部解析见下文
8}Source: SkeletonLoader.java
JSON 格式的头部解析
JSON 骨骼文件的头部位于顶层 skeleton 节点。SkeletonLoader 用 fastjson2 解析后逐字段提取,并对缺失的 fps 提供 30.0f 兜底:
1// As JSON:
2String content = new String(is.readAllBytes(), StandardCharsets.UTF_8);
3JSONObject jsonData = Objects.requireNonNull(JSON.parseObject(content),
4 "Cannot load skel json");
5JSONObject skelData = Objects.requireNonNull(jsonData.getJSONObject("skeleton"),
6 "Cannot find skeleton data in skel json");
7hash = skelData.getString("hash");
8version = skelData.getString("spine");
9x = skelData.getFloatValue("x");
10y = skelData.getFloatValue("y");
11width = skelData.getFloatValue("width");
12height = skelData.getFloatValue("height");
13nonEssential = false;
14fps = skelData.containsKey("fps") ? skelData.getFloatValue("fps") : 30.0f;
15images_path = skelData.getString("images");
16audio_path = skelData.getString("audio");
17strings = Collections.emptyList();
18headerSize = 0;Source: SkeletonLoader.java
注意两个有意为之的语义差异:JSON 路径下 nonEssential 恒为 false、strings 恒为空列表、headerSize 为 0——因为这些字段只对二进制格式有意义(JSON 的字符串没有共享字符串池的概念,也不存在"头部字节长度")。
二进制格式的头部解析
二进制 .skel 文件头部没有自描述结构,SkeletonLoader 按固定布局手工解码,并使用 long[] bytesRead 单元素数组作为可变的读取偏移游标:
1// As binary:
2long[] bytesRead = {0};
3hash = readString(is, bytesRead);
4version = readString(is, bytesRead);
5x = readFloat(is, bytesRead);
6y = readFloat(is, bytesRead);
7width = readFloat(is, bytesRead);
8height = readFloat(is, bytesRead);
9nonEssential = readBoolean(is, bytesRead);
10float fps;
11String imagesPath, audioPath;
12if (nonEssential) {
13 fps = readFloat(is, bytesRead);
14 imagesPath = readString(is, bytesRead);
15 if (imagesPath.isEmpty())
16 imagesPath = null;
17 audioPath = readString(is, bytesRead);
18 if (audioPath.isEmpty())
19 audioPath = null;
20} else {
21 fps = 30.0f;
22 imagesPath = null;
23 audioPath = null;
24}
25int stringCount = readVarInt(is, bytesRead);
26ArrayList<String> strings = new ArrayList<>(stringCount);
27for (int i = 0; i < stringCount; i++) {
28 strings.add(readString(is, bytesRead));
29}
30this.strings = Collections.unmodifiableList(strings);
31this.fps = fps;
32this.images_path = imagesPath;
33this.audio_path = audioPath;
34headerSize = bytesRead[0];Source: SkeletonLoader.java
关键点:
nonEssential分支:只有当导出时勾选了 non-essential 数据时,头部才包含fps、images、audio三个字段;否则使用30.0f默认值并置空路径。这是对 Spine 二进制布局的忠实还原。headerSize:记录头部 + 字符串池共消耗的字节数。它是后续fixed()能够"跳过头部、原样复制剩余数据"的基础。- 字符串池不可变化:
strings被包装为Collections.unmodifiableList,防止外部篡改后与headerSize失配。
格式探测:isJson(FileHandle)
JSON 与二进制的探测通过读取文件前 1 KiB 判断内容特征完成(方法实现位于文件末尾的辅助读取函数区域,isJson(file) 在构造函数第 50 行被调用)。JSON 骨骼文件以文本形式开头(可被解析为 JSON 对象),而二进制文件则以原始字符串字节开头。这一探测决定了后续所有解析路径的走向。
缺陷检测:needFix()
1public boolean needFix() {
2 if (isJson) {
3 return false;
4 }
5 for (String s : strings) {
6 byte[] utf8Bytes = s.getBytes(StandardCharsets.UTF_8);
7 byte[] defaultCharsetBytes = s.getBytes(Charset.defaultCharset());
8 if (s.matches(".*\\s") || !Arrays.equals(utf8Bytes, defaultCharsetBytes)) {
9 return true;
10 }
11 }
12 return false;
13}Source: SkeletonLoader.java
该方法只对二进制文件生效,检测两类缺陷:
- 尾部空白(
s.matches(".*\\s")):对应 Issue #150,某些导出器生成的字符串以空白结尾,会导致骨骼路径匹配失败。 - 字符集不可往返(
!Arrays.equals(utf8Bytes, defaultCharsetBytes)):对应 Issue #160。Spine 运行时在读取二进制字符串时使用平台默认字符集解码字节,如果字符串中包含非 ASCII 字符(如中文骨骼/动画名),默认字符集(尤其在 Windows 上常为 GBK)与 UTF-8 的字节表示不一致,解码后会得到乱码。检测方式是比较"UTF-8 字节序列"与"默认字符集字节序列"是否逐字节相同。
修复流程:fixed() 与 fixString()
fixed() 会生成一个新的临时骨骼文件(前缀 fixed_,位于 tempDirPath 指定的临时目录,注册 deleteOnExit() 以便退出时清理),重写头部与字符串池,然后把原文件头部之后的所有数据原样追加:
1File tempFile = File.createTempFile("fixed_", "_" + file.name(), new File(tempDirPath));
2tempFile.deleteOnExit();
3FileHandle tempHandle = new FileHandle(tempFile);
4try (OutputStream os = tempHandle.write(false)) {
5 // Write header
6 writeString(os, ""); // Empty hash
7 writeString(os, version);
8 writeFloat(os, x);
9 writeFloat(os, y);
10 writeFloat(os, width);
11 writeFloat(os, height);
12 writeBoolean(os, nonEssential);
13 if (nonEssential) {
14 writeFloat(os, fps);
15 writeString(os, images_path != null ? images_path : "");
16 writeString(os, audio_path != null ? audio_path : "");
17 }
18 // Write strings pool
19 List<String> fixedStrings = strings.stream().map(SkeletonLoader::fixString).toList();
20 writeVarInt(os, fixedStrings.size());
21 for (String s : fixedStrings) {
22 writeString(os, s);
23 }
24 // Write the remaining data
25 try (InputStream is = file.read()) {
26 long toSkip = headerSize;
27 while (toSkip > 0) {
28 long skipped = is.skip(toSkip);
29 if (skipped <= 0) {
30 throw new IOException("Failed to skip header");
31 }
32 toSkip -= skipped;
33 }
34 byte[] buffer = new byte[8192];
35 int read;
36 while ((read = is.read(buffer)) != -1) {
37 os.write(buffer, 0, read);
38 }
39 }
40}
41return new SkeletonLoader(tempHandle);Source: SkeletonLoader.java
三个值得注意的设计决策:
hash被置空:修复后的文件不再保留原始hash(写入空字符串)。因为重写头部后文件内容已变,旧hash不再具备校验意义。skip循环防御:InputStream.skip()不保证一次跳过全部请求字节数(返回 0 或负数表示流结束),因此用while循环累计跳过,失败时抛出IOException("Failed to skip header")。- 8 KiB 缓冲复制:剩余数据(骨骼、插槽、动画等主体)以 8 KiB 缓冲逐块复制,避免一次性把几十 MB 读入内存。
字符串修复逻辑集中在 fixString():
1protected static String fixString(String s) {
2 // Remove trailing whitespaces, see ArkPets Issue #150.
3 // https://github.com/isHarryh/Ark-Pets/issues/150
4 s = s.replaceAll("\\s+$", "");
5
6 // Re-encode non-ASCII characters into the default charset, see ArkPets Issue #160.
7 // https://github.com/isHarryh/Ark-Pets/issues/160
8 byte[] utf8Bytes = s.getBytes(StandardCharsets.UTF_8);
9 byte[] defaultCharsetBytes = s.getBytes(Charset.defaultCharset());
10 if (!Arrays.equals(utf8Bytes, defaultCharsetBytes))
11 s = new String(utf8Bytes, Charset.defaultCharset());
12
13 return s;
14}Source: SkeletonLoader.java
new String(utf8Bytes, Charset.defaultCharset()) 这一步是关键:它把"按 UTF-8 解码得到的字符串"的 UTF-8 字节序列,用默认字符集重新编码成字节写回文件——相当于让非 ASCII 字符的字节在默认字符集下重新落位,从而与 Spine 运行时后续的默认字符集解码保持一致。这是一个针对平台字符集差异的兼容性 hack,而非通用编码转换。
核心流程(Core Flow)
流程要点:
- 构造即解析头部——元数据在实例化时一次到位,之后所有判断(是否修复、用哪个解析器)都基于已缓存的状态。
- 修复是惰性且显式的——
needFix()与fixed()分离,调用方可以只在确有缺陷时才触发磁盘写操作;fixed()返回新实例而不修改原实例(needFix()为假时直接返回this),保持了不可变风格。 - Spine 运行时只接触"干净"文件——修复完成后,
SkeletonJson/SkeletonBinary拿到的文件一定是可以用平台默认字符集正确解码的。
骨骼数据装配:loadSkeletonDataWith()
重载一:已构建的 TextureAtlas
1public SkeletonData loadSkeletonDataWith(TextureAtlas atlas, float scale) {
2 if (isJson) {
3 SkeletonJson json = new SkeletonJson(atlas);
4 json.setScale(scale);
5 return json.readSkeletonData(file);
6 } else {
7 SkeletonBinary binary = new SkeletonBinary(atlas);
8 binary.setScale(scale);
9 return binary.readSkeletonData(file);
10 }
11}Source: SkeletonLoader.java
该方法是真正的"解析委托":按格式选择 SkeletonJson 或 SkeletonBinary,设置 scale 后调用 readSkeletonData(file)。注意它读取的是实例持有的 file——因此如果调用了 fixed(),应使用修复后返回的新实例来调用此方法。
重载二:从图集文件构建(含 mipmapping 强制开关)
1public SkeletonData loadSkeletonDataWith(FileHandle atlasFile, float scale, boolean forceMipmap) {
2 TextureAtlasData atlasData = new TextureAtlasData(atlasFile, atlasFile.parent(), false);
3 if (forceMipmap) {
4 for (TextureAtlasData.Page page : atlasData.getPages()) {
5 page.minFilter = TextureFilter.MipMapLinearLinear;
6 page.useMipMaps = true;
7 }
8 }
9 return loadSkeletonDataWith(new TextureAtlas(atlasData), scale);
10}Source: SkeletonLoader.java
设计意图:先用 TextureAtlasData(纯数据、未上传 GPU 的图集描述)检查每一页(Page),在构造真正的 TextureAtlas 之前把 minFilter 改为 MipMapLinearLinear 并开启 useMipMaps。之所以在 TextureAtlasData 阶段修改而不是构造之后,是因为 mipmap 标志影响纹理上传,事后修改已无效果。桌宠渲染时骨骼会被大幅缩小显示,强制三线性 mipmap 采样能显著抑制纹理闪烁与摩尔纹。
使用示例
基本用法:读取元数据并检测缺陷
1FileHandle skelFile = new FileHandle(new File(modelDir, "model.skel"));
2SkeletonLoader loader = new SkeletonLoader(skelFile);
3// 元数据可直接读取
4System.out.println("Spine version: " + loader.version);
5System.out.println("FPS: " + loader.fps);
6// 缺陷检测与修复(链式,无需修复时返回原实例)
7if (loader.needFix()) {
8 loader = loader.fixed();
9}Source: SkeletonLoader.java(示例基于构造函数、needFix() 与 fixed() 的实际签名编写)
完整装配:图集 + 缩放 + mipmaps
FileHandle atlasFile = new FileHandle(new File(modelDir, "model.atlas"));
SkeletonData skeletonData = loader.loadSkeletonDataWith(atlasFile, 0.5f, true);
// skeletonData 可用于创建 Skeleton 实例并交给渲染管线Source: SkeletonLoader.java(示例基于 loadSkeletonDataWith(FileHandle, float, boolean) 重载编写)
配置与常量
| 项 | 类型 / 位置 | 默认值 | 说明 |
|---|---|---|---|
MAX_SKELETON_FILE_SIZE | protected static final int,SkeletonLoader | 64 << 20(64 MiB) | 骨骼文件大小上限,超出抛出 IOException("Skeleton file is to large") |
tempDirPath | Const.PathConfig.tempDirPath(静态导入) | 由 Const 定义 | 修复用临时文件所在目录 |
默认 fps(JSON 缺失 / 二进制 non-essential 关闭) | float | 30.0f | 头部未提供帧率时的兜底值 |
默认采样过滤(forceMipmap=true) | TextureFilter | MipMapLinearLinear | 三线性 mipmap 采样,用于缩放渲染防闪烁 |
API 参考
SkeletonLoader(FileHandle file): SkeletonLoader
描述:从文件句柄初始化骨骼加载器,立即完成头部解析。
参数:
file(com.badlogic.gdx.files.FileHandle):骨骼文件句柄,JSON 或二进制格式均可。
抛出:
IOException:文件超过 64 MiB,或读取/解析过程中发生 I/O 错误。
isJson(): boolean
描述:返回骨骼文件是否为 JSON 格式(反之为二进制格式)。
needFix(): boolean
描述:判断二进制骨骼是否需要修复。JSON 文件恒返回 false。返回 true 的条件:任一字符串池字符串以空白结尾,或其 UTF-8 字节与默认字符集字节不一致。
参见:Issue #150、Issue #160。
fixed(): SkeletonLoader
描述:返回修复后的骨骼加载器实例。无需修复时返回 this;否则在 tempDirPath 下生成 fixed_* 前缀临时文件(退出时自动删除),重写头部与字符串池并复制剩余数据,返回基于该临时文件的新实例。
抛出:IOException:临时文件创建/写入失败,或跳过原文件头部失败("Failed to skip header")。
loadSkeletonDataWith(TextureAtlas atlas, float scale): SkeletonData
描述:用已构建的纹理图集解析骨骼数据。
参数:
atlas(TextureAtlas):附着到骨骼数据的纹理图集。scale(float):应用到骨骼数据的缩放系数。
返回:Spine 运行时的 com.esotericsoftware.spine.SkeletonData 实例。
loadSkeletonDataWith(FileHandle atlasFile, float scale, boolean forceMipmap): SkeletonData
描述:从图集文件构建纹理图集(可强制 mipmap)后解析骨骼数据。
参数:
atlasFile(FileHandle):.atlas图集文件句柄。scale(float):缩放系数。forceMipmap(boolean):是否对所有图集页强制开启MipMapLinearLinear与useMipMaps。
返回:SkeletonData 实例。
失败模式、边界与并发
| 场景 | 行为 | 源码依据 |
|---|---|---|
| 文件超过 64 MiB | 构造函数抛出 IOException("Skeleton file is to large") | L47-L49 |
JSON 解析失败 / 缺少 skeleton 节点 | Objects.requireNonNull 抛出带消息的 NullPointerException("Cannot load skel json" / "Cannot find skeleton data in skel json") | L56-L59 |
skip() 无法跳过头部字节 | 抛出 IOException("Failed to skip header") | L173-L176 |
| 临时文件残留 | deleteOnExit() 保证 JVM 正常退出时清理;异常退出可能残留 | L146-L147 |
| 字符集不可往返的字符串 | fixString() 重编码为默认字符集字节 | L230-L233 |
| 尾部空白字符串 | fixString() 以 replaceAll("\\s+$", "") 去除 | L226 |
| 并发使用 | 类无可变共享状态(构造完成后所有公开字段为 final),实例可以安全地在多线程间共享读取;但 fixed() 会产生磁盘写副作用,建议由单一加载流程调用 | L31-L39 |
边界情况补充:
- JSON 与二进制的语义不对称:JSON 路径下
nonEssential/strings/headerSize没有真实含义(分别固定为false/ 空列表 /0),消费这些字段前应先检查isJson()。 - 修复不改变原始文件:
fixed()始终写新临时文件,用户模型目录中的原文件保持只读,这是对用户资产的安全承诺。 headerSize与字符串池强耦合:fixed()复制剩余数据时依赖headerSize精确等于"头部 + 字符串池"的字节数;这也是strings被设为不可变列表的原因。
性能与运维要点
- 内存控制:64 MiB 上限 + 8 KiB 缓冲流复制,修复流程的峰值内存占用与文件大小解耦(字符串池本身按
stringCount预分配ArrayList容量)。 - 一次性成本模型:头部解析在构造时完成一次;
needFix()是纯内存判断(无 I/O);fixed()的成本 = 一次完整文件复制;loadSkeletonDataWith()是最重的操作(完整 Spine 解析 + GPU 纹理上传)。调用方应避免对同一文件重复调用fixed()与loadSkeletonDataWith()。 - 运维提示:
tempDirPath下的fixed_*_*.skel临时文件在 JVM 非正常退出(崩溃、被 kill)时不会清理,长期运行可考虑定期清理该目录。
扩展点
- 新增头部字段:二进制头部解析与
fixed()中的头部重写是镜像关系(读:L75-L101;写:L151-L162)。若 Spine 版本升级导致头部布局变化,两处必须同步修改,否则headerSize失配会破坏fixed()的数据复制。 - 新的缺陷类型:在
needFix()中添加检测条件,并在fixString()(或针对二进制主体的新修复函数)中实现对应修复逻辑即可,fixed()的框架无需改动。 - 其他格式支持:
isJson(FileHandle)探测与loadSkeletonDataWith()的格式分流是仅有的两处格式相关分支,新增格式(例如加密骨骼)时需同时扩展这两处及头部解析器。
相关链接(Related Links)
- SkeletonLoader.java 源文件
- Issue #150:骨骼字符串尾部空白问题
- Issue #160:二进制骨骼字符集问题
- Spine 官方运行时文档
- 模型列表与元数据管理(
ModelItem/ModelsDataset)见模型资产管理的兄弟页面 - 渲染绘制(
SpineRenderPass)见渲染相关页面