Repository Wiki
isHarryh/Ark-Pets

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 两个解析器,但直接把用户提供的文件喂给运行时会遇到几个现实问题:

  1. 需要预读元数据:在加载完整骨骼之前,Ark-Pets 需要先知道骨骼的版本号、hash、包围盒、fps 等头部信息(例如用于模型列表展示与兼容性判断),而这些信息只有解析头部才能获得。
  2. 社区导出的骨骼文件存在缺陷:某些第三方导出工具生成的二进制骨骼会携带尾部空白字符的字符串(Issue #150),或包含无法在默认字符集下无损往返的字符串(Issue #160),后者会让 Spine 运行时(以平台默认编码读取字节)解析出乱码甚至崩溃。
  3. 需要对纹理图集做统一缩放与 mipmapping 控制:桌宠渲染时使用 scale 缩放骨骼,且在小尺寸渲染下需要强制 mipmaps 以避免闪烁。

SkeletonLoader 正是为解决这些问题而存在的薄封装层。它读取文件头部,把缺陷文件修复为临时文件,再委托 Spine 官方运行时完成真正的 SkeletonData 解析。

架构(Architecture)

Loading diagram...

层次关系说明:

  • 头部解析: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 字段上,供下游(模型列表、兼容性检查等)直接读取:

java
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)是防御性上限,防止异常巨大的骨骼文件把整个文件读入内存。

构造函数在开头即做尺寸校验,随后按格式分流:

java
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 兜底:

java
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 单元素数组作为可变的读取偏移游标:

java
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()

java
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

该方法只对二进制文件生效,检测两类缺陷:

  1. 尾部空白(s.matches(".*\\s")):对应 Issue #150,某些导出器生成的字符串以空白结尾,会导致骨骼路径匹配失败。
  2. 字符集不可往返(!Arrays.equals(utf8Bytes, defaultCharsetBytes)):对应 Issue #160。Spine 运行时在读取二进制字符串时使用平台默认字符集解码字节,如果字符串中包含非 ASCII 字符(如中文骨骼/动画名),默认字符集(尤其在 Windows 上常为 GBK)与 UTF-8 的字节表示不一致,解码后会得到乱码。检测方式是比较"UTF-8 字节序列"与"默认字符集字节序列"是否逐字节相同。

修复流程:fixed() 与 fixString()

fixed() 会生成一个新的临时骨骼文件(前缀 fixed_,位于 tempDirPath 指定的临时目录,注册 deleteOnExit() 以便退出时清理),重写头部与字符串池,然后把原文件头部之后的所有数据原样追加:

java
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():

java
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)

Loading diagram...

流程要点:

  1. 构造即解析头部——元数据在实例化时一次到位,之后所有判断(是否修复、用哪个解析器)都基于已缓存的状态。
  2. 修复是惰性且显式的——needFix() 与 fixed() 分离,调用方可以只在确有缺陷时才触发磁盘写操作;fixed() 返回新实例而不修改原实例(needFix() 为假时直接返回 this),保持了不可变风格。
  3. Spine 运行时只接触"干净"文件——修复完成后,SkeletonJson / SkeletonBinary 拿到的文件一定是可以用平台默认字符集正确解码的。

骨骼数据装配:loadSkeletonDataWith()

重载一:已构建的 TextureAtlas

java
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 强制开关)

java
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 采样能显著抑制纹理闪烁与摩尔纹。

使用示例

基本用法:读取元数据并检测缺陷

java
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

java
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_SIZEprotected static final int,SkeletonLoader64 << 20(64 MiB)骨骼文件大小上限,超出抛出 IOException("Skeleton file is to large")
tempDirPathConst.PathConfig.tempDirPath(静态导入)由 Const 定义修复用临时文件所在目录
默认 fps(JSON 缺失 / 二进制 non-essential 关闭)float30.0f头部未提供帧率时的兜底值
默认采样过滤(forceMipmap=true)TextureFilterMipMapLinearLinear三线性 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() 的格式分流是仅有的两处格式相关分支,新增格式(例如加密骨骼)时需同时扩展这两处及头部解析器。

Sources

(1 files)