在 Spine 角色换装场景里,骨骼和动画可以复用,但身体、眼睛、头发、服饰等部件会带来大量纹理。PNG 在磁盘上看起来并不大,加载成 GPU 纹理之后,占用却可能增长很多。
这次给 Spine iOS 接入 ASTC,主要想解决三个问题:让运行时能够读取 ASTC 文件,避免把压缩纹理展开成 RGBA,并把这些纹理传到现有的 Metal 渲染器。动画、骨骼和换装逻辑继续沿用 Spine。
本文以 4.2_astc 分支的三个提交为线索,记录资源转换、加载、渲染接入和多页换装的实现,也说明当前方案还需要补齐的地方。
背景:PNG 文件大小与纹理内存是两回事
原来的 Spine iOS 加载链路可以概括为:
1 | PNG 文件 → UIImage / CGImage → MTKTextureLoader → RGBA 纹理 → Metal 渲染 |
PNG 是图片文件的压缩格式。经过图片解码后,假设创建的是 RGBA8 纹理,每个像素需要 4 字节。只考虑一层 mipmap 的像素数据,计算方式是:
1 | RGBA8 数据大小 = width × height × 4 |
仓库里的 spineboy 页尺寸为 1024 × 256,对应:
1 | 1024 × 256 × 4 = 1,048,576 字节 = 1 MiB |
这张 PNG 的文件大小是 244,861 字节,约 239 KiB。文件压得小,上传后的 RGBA 数据仍然有 1 MiB。资源页越多,这个差异越值得关注。
ASTC(Adaptive Scalable Texture Compression)是一种面向 GPU 的有损纹理压缩格式。支持它的硬件能够直接采样压缩纹理,在采样过程中解码,因此 CPU 不需要先把整张图片展开为 RGBA。
目标链路变成:
1 | ASTC 文件 → 读取压缩块 → ASTC 格式的 MTLTexture → Metal 渲染 |
ASTC 省下的是纹理数据和图片解码工作。文件读取、文件头解析、暂存 buffer 和 GPU 上传仍然存在,不能把它理解成整个加载过程没有 CPU 开销。
ASTC 的块大小如何影响体积
ASTC 每个压缩块固定为 128 bit,也就是 16 字节。块覆盖的像素越多,每个像素平均分到的 bit 越少。
| 块大小 | 平均 bit / pixel | 大尺寸、整块图片相对 RGBA8 的数据量比值 |
|---|---|---|
| 4×4 | 8.00 | 约 4 倍 |
| 6×6 | 3.56 | 约 9 倍 |
| 8×8 | 2.00 | 约 16 倍 |
| 12×12 | 0.89 | 约 36 倍 |
这些比值只比较基础层的纹理数据,不包含 GPU 分配对齐、文件头或 mipmap。
对于二维 ASTC,精确的压缩数据大小是:
1 | blocksWide = ceil(width / blockX) |
这里前面的 16 是裸 .astc 文件头。宽高不必是块大小的整数倍,边缘不足一个块的区域也占一个完整压缩块。
spineboy 使用 8×8,因此:
1 | ceil(1024 / 8) × ceil(256 / 8) × 16 |
加上文件头,文件正好是 65,552 字节。与同尺寸 RGBA8 的基础层数据相比,减少了 16 倍。
块越大,编码误差通常越明显。细线、眼睛、透明边缘和渐变不一定适合直接用 8×8,可以从 4×4、6×6 开始比较。这个项目的示例统一用了 8×8,它是本次资源的选择,并不是所有 Spine 角色的最佳参数。
实现过程:三个提交分别完成了什么
对照基础分支 4.2,ASTC 分支只有三次新增提交:
| 提交 | 日期 | 内容 |
|---|---|---|
cfa4c8c1d · spine astc ios demo |
2026-07-28 | 增加 ASTC 加载器与扩展 API,打通 Metal 渲染,接入 spineboy 示例 |
6219ef34a · convert sh |
2026-07-28 | 增加 PNG → ASTC 转换脚本 |
98b5d1ace · spine astc |
2026-07-30 | 增加 189 页 ASTC 资源和动态组合皮肤示例 |
第一笔完成了核心实现。第二、三笔没有继续修改 Sources/Spine 中的 ASTC 加载器或渲染器,分别补充资源生产工具和复杂业务场景。
这个区分很有用:第三笔可以说明方案接入了多页换装示例,但不能据此认定加载失败、页索引或设备兼容问题已经修复。
代码结构:从文件到渲染器
这次主要涉及六个文件,均位于 spine-ios/Sources/Spine/:
| 文件 | 职责 |
|---|---|
SpineASTCLoader.swift |
检测数据格式,解析 ASTC 头,创建并上传 Metal 纹理;普通图片交给 MTKTextureLoader |
Spine.Generated+Extensions+ASTC.swift |
解析 atlas、选择页文件,返回 Atlas 与 [MTLTexture] |
SkeletonDrawableWrapper+ASTC.swift |
加载骨骼,将 Metal 纹理保存到 drawable wrapper |
SpineUIView+ASTC.swift |
提供带 useASTCIfAvailable 参数的 Bundle 加载入口 |
SpineUIView.swift |
从 drawable 取出纹理并传给渲染器 |
Metal/SpineRenderer.swift |
优先使用已加载的 Metal 纹理,按 atlas 页索引绑定纹理 |
具体调用链如下:
1 | SpineUIView(useASTCIfAvailable: true) |
Spine 的 C++ atlas 解析、骨骼数据加载、动画更新和顶点生成仍然沿用原有实现。ASTC 接入集中在资源加载与纹理传递这两段。
先由 atlas 决定页顺序,再选择页文件
Atlas 扩展先通过 spine_atlas_load 解析文本,然后按原生 atlas 的页顺序取得文件名。
Bundle 加载时,文件选择逻辑是:
1 | let nameWithoutExt = (name as NSString).deletingPathExtension |
例如 atlas 引用的是 spineboy-pma.png,加载器会先寻找 spineboy-pma.astc。找不到时才读取原来的 PNG,所以接入 ASTC 并不一定要改 atlas。
分支中还提供了 Atlas.fromFileWithMetalTextures,用于从 atlas 所在文件夹查找页资源。但是 UIView 和 wrapper 的便利 ASTC 入口目前只实现了 Bundle 版本;原有文件、HTTP 入口不会自动切换到这套逻辑。
这里的回退有一个边界:它只处理“同名 ASTC 不存在”。如果 ASTC 存在但损坏或无法上传,并不会重试 PNG。如果 atlas 本身引用 .astc,文件不存在后再加载原文件,仍然是在找同一个 .astc,也不能自动回退到 PNG。
Wrapper 如何兼容原来的 UIImage API
原来的 SkeletonDrawableWrapper 接收 atlasPages: [UIImage]。这次没有直接改掉这个公开接口,而是为每个 Metal 纹理创建一个 1×1 的 UIImage 占位,再通过 Objective-C 关联对象保存真实纹理:
1 | objc_setAssociatedObject( |
渲染时取的是 wrapper.metalTextures,占位图不包含原始图片像素,也不会被用于 ASTC 渲染。
这样能够较少改动现有 API,但也有代价:atlasPages 不再代表真实纹理的尺寸和内容。其他读取 UIImage、做导出或 CPU 绘制的功能,不能假设 ASTC drawable 中的这些占位图仍然可用。长期维护时,可以考虑把真实纹理和资源所有权变成 wrapper 的明确属性。
加载核心:ASTC 压缩数据如何上传到 Metal
读取 16 字节文件头
这套实现读取的是裸 .astc 文件,而不是 KTX / KTX2 容器。文件头布局如下:
| 偏移 | 字节数 | 含义 |
|---|---|---|
| 0 | 4 | Magic,文件字节为 13 AB A1 5C |
| 4 | 1 | blockX |
| 5 | 1 | blockY |
| 6 | 1 | blockZ |
| 7 | 3 | width,小端整数 |
| 10 | 3 | height,小端整数 |
| 13 | 3 | depth,小端整数 |
Magic 按小端解释为 0x5CA1AB13。本项目使用二维 LDR 纹理,因此正常资源的 blockZ 和 depth 都是 1。
分支中的宽高解析代码是:
1 | let blockX = header[4] |
文件头本身没有一个字段能直接告诉加载器应该用 LDR linear 还是 sRGB 采样,编码方式与 Metal 像素格式需要由资源流程约定。
创建压缩纹理
根据块尺寸映射 Metal 格式。例如:
1 | 4×4 → .astc_4x4_ldr |
完整映射支持 14 种标准二维块尺寸,包括 5×4、8×6 等非正方形块。创建纹理时使用该压缩格式:
1 | let descriptor = MTLTextureDescriptor.texture2DDescriptor( |
这里的 pixelFormat 不能换成 .rgba8Unorm,否则纹理不再是 ASTC。当前纹理只有基础层,不生成 mipmap。
通过 staging buffer 和 blit 上传
.private 纹理由 GPU 使用,因此先把压缩数据放入 CPU 可写的共享 buffer,再通过 blit 复制到纹理。
下面是分支中的核心上传代码摘录,省略了纹理、队列和 buffer 创建时的错误处理:
1 | let imageData = data.subdata(in: 16..<data.count) |
bytesPerRow 按“每行压缩块数 × 16”计算,不能沿用 RGBA 的 width × 4。上传的 buffer 不包含文件头;copy 中的纹理尺寸仍然用原始像素宽高。
实际上传还需要满足目标设备的 Metal blit 对齐和压缩格式边界要求。应打开 Metal API Validation 验证整块尺寸、非整块尺寸和很小的纹理,不能仅根据文件长度正确就认定 GPU 上传一定成功。
当前实现每页都会新建 command queue,并同步等待上传完成。这样便于保证返回的纹理已就绪,但多页资源的加载成本会累加。后续可以复用队列、批量上传和异步通知,同时保持渲染前的同步关系。
渲染接入:沿用现有 Shader
渲染器原来只接收 [UIImage],内部再创建纹理。现在同时支持外部传入的 [MTLTexture]:
1 | if let metalTextures = metalTextures { |
上面是分支初始化逻辑的简化摘录。有 Metal 纹理时直接使用,避免再次走 UIImage 转换。
绘制时仍然根据 renderCommand.atlasPage,从 textures 数组中取出纹理并绑定给 fragment shader。Shader 的采样方式不需要为 ASTC 增加分支:
1 | const half4 colorSample = colorTexture.sample( |
压缩格式由 Metal 纹理对象描述,解码由支持 ASTC 的 GPU 完成。动画状态、UV、顶点颜色和混合模式仍按原有逻辑处理。
需要注意,本分支 Shader 的 min / mag filter 仍然是 nearest。即使 atlas 写了 filter: Linear, Linear,也没有在这次接入中增加按 atlas 配置创建 sampler 的逻辑。ASTC 支持与采样过滤是两个独立问题。
色彩和透明度:编码器与渲染配置必须一致
为什么使用 -cl 和 LDR 格式
原来的普通图片纹理加载配置是:
1 | options: [ |
这次 ASTC 使用 .astc_*_ldr,脚本使用 astcenc -cl,与现有“不进行 sRGB 采样转换”的行为保持一致。
这里容易混淆两个概念:
.SRGB: false表示采样时不做 sRGB 到线性的转换,不代表 PNG 原始像素已经变成线性光数据。- 编码器的
-cl/-cs是编码 profile,Metal 的_ldr/_srgb决定纹理采样的解释;单改其中一个并不能保证颜色正确。
如果之后将整个渲染链路切换为线性光照或 sRGB 输出,需要一起检查图片像素、编码 profile、纹理格式、顶点颜色、混合和渲染目标。
提交历史里第一笔就已经使用 LDR,第二笔只是补上匹配的转换脚本。仓库说明文档里仍有 sRGB 的旧描述,本文以实际 Swift 枚举和脚本参数为准,不把它写成一次独立的色差修复。
PMA 不会因为 ASTC 接入而自动处理
Spine atlas 中的 pma: true 表示使用预乘 alpha:
1 | storedRGB = originalRGB × alpha |
渲染器继续从 atlas.isPma 决定混合因子。ASTC 加载器不会对像素做预乘,也不会替资源修正 atlas 的 PMA 声明。
因此源 PNG 已经是 PMA 时,转换后仍应保持 PMA,不能再次预乘;源图片使用 straight alpha 时,则需要让 atlas 和混合方式与之匹配。编码器提供 -pp-premultiply,但本文脚本没有启用它,不能仅凭文件名带 pma 就决定加上这个参数。
检查画质时,建议把角色分别放到浅色、深色和棋盘背景上,特别观察发丝、眼睛、轮廓和半透明部件。这些位置最容易暴露预乘状态不一致或压缩误差。
资源转换脚本:参数、完整代码与使用方式
安装编码器与单文件转换
转换发生在离线资源生产阶段,iOS 运行时不负责 ASTC 编码。macOS 可以安装 Arm 的 astcenc:
1 | brew install astc-encoder |
参数从左到右分别是:
| 参数 | 作用 |
|---|---|
-cl |
编码为 LDR linear profile,匹配当前运行时约定 |
spineboy-pma.png |
输入图片 |
spineboy-pma.astc |
输出裸 ASTC 文件 |
8x8 |
块大小,决定固定尺寸图片的压缩数据量 |
-medium |
编码搜索质量预设 |
在尺寸和块大小相同的情况下,-medium 换成 -thorough,不会改变文件的数据量,主要改变离线编码时间和重建画质。它与调小块尺寸不是同一种优化。
仓库脚本做了什么
6219ef34a 增加的脚本位于:
1 | spine-ios/Example/convert_astc.sh |
它定位脚本旁的 Spine iOS Example/Assets/spineboy 文件夹,转换其中的 PNG。块大小取第一个参数,默认 8x8;编码质量取第二个参数,默认 -medium。
已有 ASTC 会先备份为 .bak。转换后打印 PNG 与 ASTC 的文件大小比值,再复制 atlas,把 .png 替换成 .astc,生成 *-astc.atlas。
下面保留提交中的完整脚本,可以下载 convert_astc.sh:
1 |
|
如何运行
在 Spine 仓库根目录执行:
1 | # 8×8、medium,使用默认参数 |
生成的资源包括:
1 | Assets/spineboy/ |
下载版与仓库版一致,资源路径也是固定的。单独下载后,应把它放到同样的 Example 目录结构中,或者修改 ASSETS_DIR 指向自己的资源文件夹。它不接受“输入目录”参数,也不递归扫描子文件夹。
如果保留原 atlas 的 PNG 引用,ASTC 入口也能通过同名优先策略找到压缩纹理,并在缺少该 ASTC 时尝试 PNG。如果使用生成的 ASTC 专用 atlas,就明确依赖其中的 ASTC 文件。
脚本当前适用范围
这个脚本适合示例资源,接入正式资源流水线时还有几个地方要完善:
- 只转换当前文件夹的
*.png,不处理 JPG 或子目录。 - atlas 替换使用全局
sed,适用于本例命名;更通用的实现应只修改页文件名,避免误改 region 名称。 - 单张转换失败后继续执行,还可能生成 ASTC atlas。CI 中应检查失败并返回非零退出码,确认所有页成功后再生成 atlas。
- 大小统计依赖
stat和bc;打印的是文件大小比值,不是 GPU 内存统计。 - 脚本不生成 mipmap,不处理 PMA,也不做设备能力检查。备份文件不必加入 App 的资源 target。
接入示例与动态换装
UIKit / Objective-C 入口
分支中的 spineboy 示例使用 ASTC 专用 atlas,并显式打开 ASTC:
1 | SpineUIView *spineView = [[SpineUIView alloc] |
useASTCIfAvailable 是选择加载路径的开关,不是完整的设备能力探测。它在这个初始化方法中是必传参数,注释中的“默认 true”并没有对应的 Swift 默认值。
.astc 和 .atlas 需要进入 App bundle。提交里已经把这两个资源加入示例项目的 Copy Bundle Resources;自己的工程接入时也要确认 target membership,只有磁盘上存在文件还不够。
加载后再组合皮肤
第三笔提交加入 SkinnedAnimationViewController,加载 skin.atlas 与 skin.skel。拿到 drawable 后,再查找并组合身体、眼睛、头发和服装部件:
1 | SpineSkin *customSkin = |
updateSkinParts: 用相同方式替换组合皮肤。换装复用已加载的 drawable 和纹理,不需要在每次换装时重新转换或加载 ASTC。
但这个示例会预先加载 atlas 的全部 189 页,并没有按当前皮肤实现懒加载。ASTC 降低了每页纹理的数据量,不等于只加载了当前可见部件。另外,自建的 SpineSkin 需要管理原生资源生命周期,正式实现应明确保存和释放替换下来的皮肤。
数据核对与验证方式
能从仓库资源确认的收益
对实际文件头和压缩数据进行统计,得到:
| 资源 | 页数 | 相同尺寸的 RGBA8 基础层数据 | ASTC 压缩块数据 | RGBA8 / ASTC |
|---|---|---|---|---|
| spineboy | 1 | 1,048,576 字节,1 MiB | 65,536 字节,64 KiB | 16 倍 |
| 换装 skin | 189 | 13,679,832 字节,约 13.05 MiB | 897,552 字节,约 0.86 MiB | 约 15.24 倍 |
换装资源的 ASTC 文件总大小还要加上 189 × 16 字节文件头,合计 900,576 字节。189 页都使用 8×8,其中 185 页至少有一个维度不是 8 的整数倍,所以整体比值低于 16。
上述统计只说明资源数据量。实际 GPU allocation 可能包含对齐、页粒度和其他开销,加载峰值也包括 Data 副本、数组和 staging buffer。不能把这里的比值当成 Instruments 的完整应用内存结果。
仓库文档有“加载时间约 50ms → 15ms”的描述,但没有配套的设备、采样方式或测试记录,因此不作为本文的已验证性能结论。
检查文件头
可以直接查看前 16 字节:
1 | xxd -l 16 "spine-ios/Example/Spine iOS Example/Assets/spineboy/spineboy-pma.astc" |
本例输出:
1 | 00000000: 13ab a15c 0808 0100 0400 0001 0001 0000 |
对应 magic 正确、块尺寸 8×8×1、图片尺寸 1024×256×1。
在目标设备上验证
文件和代码核对之后,还需要在支持 ASTC 的目标真机上检查:
- 用 GPU Frame Capture 确认纹理格式是
.astc_8x8_ldr,尺寸与 atlas 一致;不要只根据“Loaded with ASTC support”日志判断。 - 打开 Metal API Validation,覆盖多页、非整块尺寸、小尺寸纹理和上传失败。
- 对照 PNG 路径,检查亮度、颜色、透明边缘和各种混合模式。ASTC 是有损压缩,允许压缩误差,不应要求像素完全相同。
- 保持设备、动画和渲染尺寸一致,分别统计加载耗时、CPU 峰值、Metal 资源分配以及退出页面后的资源释放。
- 检查缺少 ASTC、损坏 ASTC 和设备不支持三种情况;这三种情况不能用同一条“fallback 成功”日志代替。
当前实现还需要补齐的地方
页索引必须保持一致
目前 ASTC atlas 加载循环用 try? 忽略纹理加载错误,只把成功的纹理追加到数组。假设原来的页顺序是:
1 | atlas 页索引:0=A,1=B,2=C |
渲染命令仍然用原页索引,此时页 1 会取到 C,页 2 则越界。渲染器越界时没有停止绘制,也可能继续使用此前绑定的纹理。
正式实现应保持每个 atlas 页和纹理槽一一对应:优先重试该页的普通图片;仍失败时明确报错终止,或者在同一索引保存明确的占位纹理。不能静默缩短数组。
文件校验与上传错误
当前加载器检查了 magic、最小头长度和二维块尺寸,但没有完整检查宽高、blockZ、depth 以及压缩数据长度。
可以先按块数计算预期长度,再确认基础层数据完整,最后创建纹理。最初 magic 检测使用 UnsafeRawBufferPointer.load(as: UInt32.self),也依赖指针对齐;逐字节比较或使用支持版本内的非对齐读取更稳妥。
waitUntilCompleted() 只代表命令结束,不保证执行成功。还应检查 command buffer 的 status 和 error,把 GPU 上传失败传回调用方。
生命周期、设备和线程
页文件读取异常时,原生 atlas 已经创建但还未交给 wrapper 管理,需要在失败路径调用 spine_atlas_dispose。骨骼或 wrapper 构造失败时也要清理已创建的原生资源。
关联的 Metal 纹理会随 wrapper 的对象生命周期释放,但当前 dispose() 没有主动清除这份关联对象;渲染器还可能持有同一批纹理,释放时需要一起考虑。
当前 wrapper 强制解包 MTLCreateSystemDefaultDevice(),并未实现 ASTC 设备能力检查。生产实现应显式处理无设备和不支持格式的情况,让纹理与渲染器使用同一个 Metal device,并决定是否回退普通图片。
UIView 扩展通过 Task.detached 加载,最终更新视图状态必须回到 MainActor。采用新 SDK 和严格并发检查时,还需要验证这条线程边界。后台完成文件读取与资源处理,再在主线程接入视图,是更明确的安排。