求助,角色立绘的背景不是spine文件该如何组合

我在解包游戏:星落
游戏链接:星落安卓版游戏APK下载

的时候遇到了一个问题

角色立绘的背景文件是单独的一个图片,无法随skel文件一起导入spine

spine.zip (6.3 MB)

↑这是我导出的立绘文件和cardspine_10111_2.prefab的ab包

那么游戏里面是如何将角色与背景组合起来的呢?

我又该如何拼出完整的立绘?

游戏里应该很简单吧,游戏加载画面都有优先级的,把人物优先级调高人物就在背景前面了就,自己弄的话不知道啊 :grinning_face:

星落星落角色立绘提取_星落wiki|GameKee 你看看,我试了下 确实可以把背景拖进去呢

我篇就是我写的,我现在是想找找精确的匹配关系,比如它们各自的坐标,目前我是靠人眼匹配的

:sweat_smile:

Spine文件怎么把文件和背景合二为一 - 讨论 - Live2DHub

应该是预制体,如果想要精确的坐标最简单是用unity加载ab包,然后加载预制体直接看transform,理论上直接看文件里也有这些信息,但是我没研究过。

而且,预制体一般就是一个skeleton一个image手动自己拖的,如果不需要特别精确自己拖就行。

里面有transform坐标,组件是spriteRenderer以及skeletonanimation。那个gameobjct应该就是预制体。

感谢,大概了解了

我没懂啊 你搞成了吗 所以坐标怎么写

我看懂了这句话 :joy:

主要是unity用不来

游戏角色立绘 Spine 拼接 — 完整技术报告

目录

  1. 背景与问题
  2. 整体架构概览
  3. 坐标提取详解(手把手)
  4. 空白 Spine 工程:统一渲染管线
  5. SpineViewer 集成
  6. 完整自动化管线
  7. 替代方案分析
  8. 附录

1. 背景与问题

1.1 立绘的组成

游戏角色的立绘并非单张图片,而是由多个独立资产层组合而成:

┌──────────────────────────────────────┐
│  cardspine_10111_2_bg.png            │  ← 背景图 (1697×1330, 普通PNG)
│                                      │
│     ┌──────────────────────┐         │
│     │ Spine 骨架动画        │         │  ← 人物主体 (.skel + .atlas + .png)
│     │ (cardspine_10111_2)   │         │     Spine 4.0.55 格式
│     └──────────────────────┘         │
│                                      │
└──────────────────────────────────────┘
  • 人物:标准的 Spine 工程(.skel 骨骼 + .atlas 图集 + .png 纹理)
  • 背景:一张独立的 PNG 图片,不在 Spine 工程内
  • 相对位置:存储在 Unity prefab(.bundle 文件)内部的 Transform 组件中

1.2 为什么背景不在 Spine 里

游戏使用 Unity 引擎,立绘在 Unity 中是一个 prefab(预制体)。prefab 包含两个子对象:

  • 一个带 SkeletonAnimation 组件的 GameObject(人物)
  • 一个带 SpriteRenderer 组件的 GameObject(背景)

Unity 引擎负责在运行时将两者按各自的 Transform 坐标叠加渲染。背景作为独立的 SpriteRenderer 而不是 Spine 附件,好处是:

  • 背景可以独立替换而不影响 Spine 工程
  • 不同皮肤的同一角色可以共用背景
  • 背景图支持 Unity 的 Sprite Atlas 合批优化

1.3 核心挑战

当资源从 bundle 中提取出来后:

  1. Spine 文件(.skel/.atlas/.png)可以用 SpineViewer 直接打开和渲染
  2. 背景图是一张独立的 PNG,无法自动与人物对齐
  3. 两者的相对坐标信息隐藏在 Unity prefab 的二进制数据中,常规解包工具(AssetStudio)不会导出 Transform 数据

本报告的目标:完整、自动化地从 bundle 中提取坐标数据,并实现用 SpineViewer 统一渲染的批量拼合。


2. 整体架构概览

2.1 资源在哪里

所有游戏资源以 Unity AssetBundle 形式托管在 CDN:

https://elpis.17995cdn.com/Android/Bundles/{hash}.bundle

每个 bundle 由 32 位十六进制 MD5 哈希命名。对于角色立绘,bundle 名称遵循固定模式:

类别 名称模式 数量 说明
角色立绘 cardspine_prefab_cardspine_XXXXX_X_prefab.bundle 290 带背景的角色卡
活动封面 ui_spine_prefab_eventcovers_XXXX_prefab.bundle 39 活动封面
战斗动画 battlespine_prefab_battlespine_XXXXX_X_prefab.bundle 329 战斗中的 Spine
NPC models_npcspine_npcspine_XXXXXXXXX.bundle 70 NPC Spine

2.2 整个流程

┌──────────────────────────────────────────────────────────────┐
│  第1步: 获取资源清单                                          │
│  CDN → updateinfo.json → versions_v*.json → arts_{hash}     │
│  → AssetStudio CLI 导出 → Arts_manifest.json (8MB)           │
├──────────────────────────────────────────────────────────────┤
│  第2步: 分类筛选                                              │
│  解析 manifest JSON, 按 bundle 名称前缀分类                   │
│  → 筛选出 cardspine_* 和 eventcover_* 类型的 bundle hash     │
├──────────────────────────────────────────────────────────────┤
│  第3步: 下载 bundle                                           │
│  CDN → {hash}.bundle → 修复 32 字节伪头部 → 解密             │
├──────────────────────────────────────────────────────────────┤
│  第4步: 提取坐标 (核心步骤, 详见 §3)                          │
│  UnityPy 加载 bundle → 遍历 GameObject 树                     │
│  → 读取 Transform → m_LocalPosition (x, y, z)                │
│  → 识别 bg/character/mesh_part 角色                          │
│  → 计算背景相对于角色的像素偏移                               │
├──────────────────────────────────────────────────────────────┤
│  第5步: 构造空白 Spine 工程 (详见 §4)                         │
│  为每张背景图生成 skeleton.json + skeleton.atlas             │
│  → 将背景偏移量直接写入 bg_root bone 的坐标                   │
│  → bg 和人物都通过 SpineViewer 统一渲染                        │
├──────────────────────────────────────────────────────────────┤
│  第6步: SpineViewer 统一渲染 + 合成                           │
│  人物 → SpineViewer CLI 导出 (--scale 4 超分)                │
│  bg 工程 → SpineViewer CLI 导出 (--scale 4, 同参数)          │
│  → 两者偏移已内置, (0,0) 对齐即可合成                          │
└──────────────────────────────────────────────────────────────┘

3. 坐标提取详解

这是整个流程的核心。我们将逐步理解:bundle 内部有什么数据结构 → 如何用代码提取 → 如何处理不同情况。

3.1 先理解:Bundle 文件里有什么

一个 bundle 文件实际上是一个 Unity AssetBundle 容器,里面包含若干序列化的 Unity 对象。对于 cardspine 类型的 bundle,通常有 20-30 个对象。

我们关心的对象类型:

Unity 类型 ClassID 包含的关键数据
GameObject 1 名称、组件列表
Transform 4 m_LocalPosition(坐标)、m_LocalScale(缩放)、m_LocalRotation(旋转)
SpriteRenderer 212 m_Sprite(引用哪个背景图)、m_Size(显示尺寸)
SkeletonAnimation 自定义 skeletonDataAsset(引用哪个骨架)
MeshRenderer 23 3D 网格渲染(特效/附加部件)
Texture2D 28 纹理的原始像素数据
TextAsset 160 .skel 和 .atlas 的原始文件内容

关键点:AssetStudio 的 “Convert” 导出模式不会导出 Transform 数据。 Transform 的坐标信息(m_LocalPosition)必须以编程方式从 bundle 二进制数据中读取。这就是我们引入 UnityPy 的原因。

3.2 Transform 在二进制层面的结构

Unity 的序列化格式中,Transform(ClassID 4)对象的字段按固定顺序排列:

偏移量    字段               类型        大小
─────────────────────────────────────────────
+0       m_LocalRotation     Quaternion   16字节 (4 × float32)
+16      m_LocalPosition     Vector3      12字节 (3 × float32)  ← 我们的目标
+28      m_LocalScale        Vector3      12字节 (3 × float32)
+40      m_Children          array        变长
...      m_Father            fileID       8字节

m_LocalPosition 就是 3 个连续的 32 位小端序浮点数 (x, y, z),紧跟在 16 字节的旋转四元数之后。例如 cardspine_10111_2_bg 的偏移数据在二进制中表示为:

0xBF BD 70 A4  0x40 96 B8 52  0x00 00 00 00
  = -0.370       = 4.710        = 0.000

UnityPy 已经帮我们完成了二进制解析,我们只需调用 cd.m_LocalPosition 即可。

3.3 第4步的完整代码(逐行解释)

3.3.1 准备工作

# 1. 安装依赖
# pip install UnityPy pycryptodome

import UnityPy
import urllib.request
import ssl
import os

# 2. 设置解密密钥(Elpis 项目专用 —— 来自 Keys.json 中的 "Elpis" 条目)
UnityPy.set_assetbundle_decrypt_key(b'yunguihaowan1234')
# 注意:这个密钥是 16 字节的 ASCII 字符串,直接作为 AES 密钥使用

3.3.2 下载并修复 bundle

CDN = 'https://elpis.17995cdn.com/Android/Bundles'
HEADERS = {
    'User-Agent': 'UnityPlayer/2021.3.45f2c1 (UnityWebRequest/1.0)',
    'X-Unity-Version': '2021.3.45f2c1',
}
ctx = ssl.create_default_context()

def download_and_fix(hash_str, save_path):
    """下载 bundle 并去掉 32 字节伪头部"""
    url = f'{CDN}/{hash_str}.bundle'
    req = urllib.request.Request(url, headers=HEADERS)
    raw = urllib.request.urlopen(req, context=ctx, timeout=15).read()
    
    # CDN 返回的文件前 32 字节是随机数据(伪头部)
    # "UnityFS" 魔数才是真正的 AssetBundle 起始位置
    idx = raw.find(b'UnityFS')
    if idx > 0:
        raw = raw[idx:]  # 丢掉前 32 字节
        print(f'   已修复: 去掉前 {idx} 字节伪头部')
    
    with open(save_path, 'wb') as f:
        f.write(raw)
    return save_path

为什么要修复? CDN 上的 bundle 文件在原始 UnityFS 数据前添加了 32 字节随机字节。这是游戏使用的 xasset 更新系统的附加头部,用于防止缓存问题。如果不剥离,UnityPy 会报错 The BundleFile is encrypted

3.3.3 核心:解析 GameObject 层级并提取坐标

def parse_prefab(bundle_path):
    """
    解析一个 bundle 文件,提取所有 GameObject 及其组件信息。
    
    返回:
      {
        'gameobject_name': {
            'type': 'background' | 'character' | 'mesh_part' | 'unknown',
            'pos': (x, y),          # Transform.m_LocalPosition
            'sprite_size': (w, h),  # 仅 SpriteRenderer 有此字段 (Unity单位)
        },
        ...
      }
    """
    # 1. 加载 bundle(UnityPy 自动处理解密)
    env = UnityPy.load(bundle_path)
    
    result = {}
    
    # 2. 遍历所有对象
    for obj in env.objects:
        # 3. 只关心 GameObject 类型
        if obj.type.name != 'GameObject':
            continue
        
        # 4. 读取 GameObject 数据
        go = obj.read()
        # go.m_Name = 物体名称, 如 "cardspine_10111_2_bg"
        # go.m_Component = 该物体挂载的所有组件
        
        info = {
            'name': go.m_Name,
            'type': 'unknown',
            'pos': (0.0, 0.0),      # 默认在原点
            'sprite_size': None,
        }
        
        # 5. 遍历该 GameObject 的所有组件
        for comp in go.m_Component:
            if comp.component is None:
                continue  # 某些组件引用可能为空
            
            # 6. 读取组件的实际数据
            cd = comp.component.read()
            type_name = type(cd).__name__
            
            # 7. 识别组件类型并提取数据
            if 'Transform' in type_name:
                # Transform 组件包含位置、缩放、旋转
                info['pos'] = (
                    cd.m_LocalPosition.x,  # float
                    cd.m_LocalPosition.y,  # float
                )
                # z 坐标在 2D UI 中通常为 0,忽略
            
            elif 'SpriteRenderer' in type_name:
                # SpriteRenderer = 背景图渲染器
                info['type'] = 'background'
                # m_Size 是背景在 Unity 世界中的显示尺寸
                # 用于计算 PPU (Pixels Per Unit)
                info['sprite_size'] = (
                    cd.m_Size.x,  # 例如 17.0
                    cd.m_Size.y,  # 例如 13.3
                )
            
            elif 'SkeletonAnimation' in type_name:
                # SkeletonAnimation = Spine 骨架动画(人物主体)
                info['type'] = 'character'
            
            elif 'MeshRenderer' in type_name:
                # MeshRenderer = 3D 网格渲染(特效/附加部件)
                info['type'] = 'mesh_part'
        
        # 8. 以 GameObject 名称为 key 存入结果
        result[go.m_Name] = info
    
    return result

3.3.4 运行示例

对于 cardspine_10111_2 这个角色,运行 parse_prefab('ab0d7aa2....bundle') 得到:

{
    'cardspine_10111_2_grp': {
        'type': 'unknown',     # 根节点,无特殊组件
        'pos': (0.0, 0.0),
    },
    'cardspine_10111_2_bg': {
        'type': 'background',  # ⭐ 这是背景
        'pos': (-0.370, 4.710),  # ⭐ 背景的坐标!
        'sprite_size': (17.0, 13.3),  # 背景在 Unity 空间中的显示尺寸
    },
    'cardspine_10111_2': {
        'type': 'character',   # ⭐ 这是人物
        'pos': (0.0, 0.0),     # 人物在原点
    },
}

3.3.5 从 Unity 坐标换算到像素偏移

def compute_pixel_offset(parse_result):
    """
    根据 parse_prefab 的返回结果,计算背景相对于角色的像素偏移。
    PPU (Pixels Per Unit) = 背景纹理原始宽度 / sprite_size.x
    多数角色 PPU ≈ 100
    """
    bg = char = None
    for name, info in parse_result.items():
        if info['type'] == 'background': bg = info
        elif info['type'] == 'character': char = info
    
    if bg is None:
        return None  # 无背景,纯角色
    
    ppu = 100  # 默认值。精确值应从 Texture2D 导出时读取原始宽度计算
    
    return {
        'bg_name': bg['name'],
        'bg_unity_pos': bg['pos'],          # 例如 (-0.370, 4.710)
        'ppu': ppu,
        'pixel_offset': (
            (bg['pos'][0] - 0) * ppu,       # dx: -37 px
            (bg['pos'][1] - 0) * ppu,       # dy: 471 px
        ),
    }

3.4 不同结构的处理

采样 290 个角色后,发现三种结构:

结构 A:纯角色(~13%)

{'cardspine_10029_1': {'type': 'mesh_part', 'pos': (0.0, 0.0)}}

→ 无背景,跳过拼接。compute_pixel_offset() 返回 None

结构 B:角色 + 背景(~65%)

{
    'cardspine_XXXXX_X_grp': {'pos': (0.0, 0.0)},
    'cardspine_XXXXX_X_bg':  {'type': 'background', 'pos': (-0.37, 4.71)},
    'cardspine_XXXXX_X':     {'type': 'character',  'pos': (0.0, 0.0)},
}

compute_pixel_offset() 计算 bg 到 char 的偏移。

结构 C:多层角色(~22%)

{
    'cardspine_XXXXX_X_grp': {'pos': (0.0, 0.0)},
    'cardspine_XXXXX_X_bg':  {'type': 'background', 'pos': (-0.31, 4.44)},
    'cardspine_XXXXX_X':     {'type': 'character',  'pos': (0.0, 0.0)},
    'cardspine_XXXXX_X_1':   {'type': 'mesh_part',  'pos': (0.0, 0.0)},
    # ... 可能有 3-8 个 mesh_part
}

→ 除背景外,还有多个 MeshRenderer 部件(通常是特效/光效),位置都在 (0,0)。mesh_part 的模型/纹理通过 AssetStudio 单独导出。


4. 空白 Spine 工程:统一渲染管线

4.1 设计理念

我们需要将人物 Spine 工程背景 PNG 合并输出为一张完整的立绘。

为什么不直接用 Python Pillow 合成?

SpineViewer 提供了 Python 无法替代的功能:

  • --scale 4 → 4 倍超分辨率输出(矢量质量放大)
  • -f Gif-f Mp4 → 直接导出带透明通道的动画
  • PMA(premultiplied alpha)正确渲染
  • 统一渲染参数确保画质一致

核心思路:将背景 PNG 包装为一个最小合法 Spine 工程,将背景偏移量直接写入 skeleton 的 bone 坐标中。然后人物和背景分别通过 SpineViewer 渲染(使用相同的 --scale 等参数),最后在 Python 中 (0,0) 对齐合成。因为偏移已经内置在 bg 工程的 bone 位置上,SpineViewer 输出的 bg 渲染图天然就和人物对齐。

4.2 skeleton.json 构造

与之前不同:bg_root bone 的坐标不是 (0,0),而是直接写入计算得到的像素偏移量。

def generate_bg_skeleton(bg_name, bg_width, bg_height, 
                          pixel_offset_x, pixel_offset_y,
                          version='3.8.75'):
    """
    为背景图生成 Spine skeleton JSON。
    
    bg_root bone 的坐标直接写入像素偏移量。
    这样 SpineViewer 渲染的 bg 图就自带偏移,与人物在 (0,0) 对齐。
    
    参数:
      pixel_offset_x, pixel_offset_y: 从 §3 提取的 bg 像素偏移
    """
    return {
        "skeleton": {
            "hash": bg_name,
            "spine": version,
            "images": "./",
            "audio": ""
        },
        "bones": [
            {
                "name": "bg_root",
                "x": pixel_offset_x,    # ← 偏移在这里!
                "y": pixel_offset_y,    # ← 偏移在这里!
            }
        ],
        "slots": [
            {
                "name": bg_name,
                "bone": "bg_root",
                "attachment": bg_name
            }
        ],
        "skins": [
            {
                "name": "default",
                "attachments": {
                    bg_name: {
                        bg_name: {
                            "width": bg_width,
                            "height": bg_height
                        }
                    }
                }
            }
        ],
        "animations": {
            "animation": {}
        }
    }

关键变化bg_root bone 的 "x""y" 直接填入像素偏移(如 -37, 471)。SpineViewer 渲染时会把这个 bone 放在该坐标,背景 attachment 挂在 bone 下面,所以渲染出的 bg 图就天然偏移到了正确位置。

4.3 skeleton.atlas 构造

def generate_bg_atlas(bg_name, img_width, img_height):
    return f"""skeleton.png
size: {img_width},{img_height}
format: RGBA8888
filter: Linear,Linear
repeat: none
{bg_name}
  rotate: false
  xy: 0,0
  size: {img_width},{img_height}
  orig: {img_width},{img_height}
  offset: 0,0
  index: -1
"""

4.4 为什么偏移内置于 bone 而非事后合成

对比两种方案:

方案A (旧): bg bone 在 (0,0) → SpineViewer 渲染 bg → Pillow paste(bg, (dx,dy))
  缺点: Pillow 不会超分, 透明通道处理粗糙, 需要手动计算画布

方案B (新): bg bone 在 (dx,dy) → SpineViewer 渲染 bg → Pillow paste(bg, (0,0))
  优点: 偏移已内置, 合成只需对齐原点, SpineViewer 超分对两者都生效

由于 SpineViewer 会自动裁剪输出到 skeleton 的包围盒,我们还需要加 --margin 参数预留足够空间,确保 bg 和人物的渲染输出尺寸一致。具体做法见 §5。

4.5 从 Unity 坐标到 bone 坐标的对应关系

Unity prefab 中的 bg pos (-0.37, 4.71) 是我们提取的原始数据。Spine 坐标系和 Unity 坐标系都是 Y 轴向上,所以直接换算即可:

bg_bone.x = bg_unity_pos.x × PPU
bg_bone.y = bg_unity_pos.y × PPU

最终写入 skeleton.json 的 "x""y" 就是这两个值。


5. SpineViewer 集成

5.1 工具信息

tools/SpineViewer/SpineViewerCLI.exe — .NET 8.0 / win-x64, v0.16.19

  • 支持 Spine 2.1 到 4.2(内建 9 套运行时)
  • 导出格式: PNG / GIF / MP4 / APNG / WebP 等
  • --scale 参数支持任意倍数超分(矢量质量)
  • --margin 参数保留边缘空白
  • 工作目录必须在 CLI 所在目录(解析原生 DLL)

5.2 关键命令

# 导出人物为超分静帧 (4x)
SpineViewerCLI export character.skel \
    -f Png -o char.png \
    -a idle --scale 4 --margin 500 \
    --atlas character.atlas

# 导出背景(偏移已内置在 skeleton.json 的 bone 上)
SpineViewerCLI export bg/skeleton.json \
    -f Png -o bg.png \
    -a animation --scale 4 --margin 500 \
    --atlas bg/skeleton.atlas

--margin 500 确保 SpineViewer 在计算包围盒时预留 500px 边缘空间,这样即使 bg 在偏移位置(比如 (-37, 471)),也不会被裁剪。人物和 bg 使用相同的 --margin 值保证输出尺寸一致。

5.3 Python 封装

import subprocess, os

SPINE_CLI = r'tools/SpineViewer/SpineViewerCLI.exe'
SPINE_DIR = os.path.dirname(SPINE_CLI)

def export_spine(skel_path, anim_name, out_path, 
                 atlas_path=None, fmt='Png', scale=4, margin=500):
    cmd = [
        SPINE_CLI, 'export', skel_path,
        '-f', fmt, '-o', out_path,
        '-a', anim_name,
        '--scale', str(scale),
        '--margin', str(margin),
        '--no-progress',
    ]
    if atlas_path:
        cmd.extend(['--atlas', atlas_path])
    
    result = subprocess.run(
        cmd, cwd=SPINE_DIR,
        capture_output=True, text=True, timeout=120
    )
    if result.returncode != 0:
        raise RuntimeError(f'SpineViewer error:\n{result.stderr[-500:]}')
    return out_path

def composite_char_and_bg(char_png, bg_png, output_path):
    """将 SpineViewer 分别导出的人物和背景合成。
    由于偏移已内置在 bg 的 bone 中,只需 (0,0) 对齐。"""
    from PIL import Image
    
    char = Image.open(char_png).convert('RGBA')
    bg = Image.open(bg_png).convert('RGBA')
    
    # 两者使用相同的 --margin, 输出尺寸应一致
    # 如果尺寸不同取最大值
    w = max(char.width, bg.width)
    h = max(char.height, bg.height)
    canvas = Image.new('RGBA', (w, h), (0, 0, 0, 0))
    
    # bg 在下,人物在上,(0,0) 对齐
    canvas.paste(bg, (0, 0), bg)
    canvas.paste(char, (0, 0), char)
    
    canvas.save(output_path, 'PNG')
    return output_path

6. 完整自动化管线

def process_cardspine(prefab_name, bundle_hash, output_dir):
    """
    处理单个角色立绘的完整流程。
    
    流程:
      1. 下载 bundle
      2. 修复 + UnityPy 解析 → 提取 Transform 偏移
      3. AssetStudio CLI 导出 → .skel, .atlas, .png, bg.png
      4. 生成 bg/skeleton.json (偏移写入 bg_root bone)
      5. 生成 bg/skeleton.atlas + 复制背景纹理
      6. SpineViewer 渲染人物 (4x, margin=500)
      7. SpineViewer 渲染背景 (4x, margin=500)
      8. (0,0) 合成 → 最终立绘
    """
    os.makedirs(output_dir, exist_ok=True)
    
    # Step 1-2: 下载 + 解析
    raw = download_bundle(bundle_hash)
    raw = fix_bundle(raw)
    fpath = os.path.join(output_dir, f'{bundle_hash}.bundle')
    with open(fpath, 'wb') as f: f.write(raw)
    
    offsets = parse_prefab(fpath)
    px_off = compute_pixel_offset(offsets)
    
    if px_off is None:
        print(f'  {prefab_name}: 无背景, 跳过')
        return None
    
    # Step 3: 导出 Spine 资产 (用 AssetStudio CLI)
    asset_dir = os.path.join(output_dir, 'assets')
    subprocess.run([
        AS_CLI, fpath, asset_dir,
        '--game', 'UnityCN', '--key_index', '23',
        '--group_assets', 'ByContainer',
        '--export_type', 'Convert', '--silent',
    ], cwd=os.path.dirname(AS_CLI), capture_output=True)
    
    # Step 4-5: 构造 bg 工程 (偏移写入 bone)
    bg_dir = os.path.join(output_dir, 'bg')
    os.makedirs(bg_dir, exist_ok=True)
    
    skeleton = generate_bg_skeleton(
        prefab_name + '_bg',
        bg_width=1697, bg_height=1330,  # 从纹理读取
        pixel_offset_x=px_off['pixel_offset'][0],
        pixel_offset_y=px_off['pixel_offset'][1],
    )
    with open(os.path.join(bg_dir, 'skeleton.json'), 'w') as f:
        json.dump(skeleton, f, indent=2)
    
    atlas = generate_bg_atlas(prefab_name + '_bg', 1697, 1330)
    with open(os.path.join(bg_dir, 'skeleton.atlas'), 'w') as f:
        f.write(atlas)
    
    # 复制背景纹理
    bg_tex = find_extracted_texture(asset_dir, '_bg')
    shutil.copy(bg_tex, os.path.join(bg_dir, 'skeleton.png'))
    
    # Step 6-7: SpineViewer 渲染
    char_skel = find_file(asset_dir, '.skel')
    char_atlas = find_file(asset_dir, '.atlas')
    
    char_png = export_spine(char_skel, 'idle', 
        os.path.join(output_dir, 'char.png'),
        char_atlas, 'Png', scale=4, margin=500)
    
    bg_png = export_spine(
        os.path.join(bg_dir, 'skeleton.json'), 'animation',
        os.path.join(output_dir, 'bg.png'),
        os.path.join(bg_dir, 'skeleton.atlas'), 'Png', scale=4, margin=500)
    
    # Step 8: (0,0) 合成
    final = composite_char_and_bg(char_png, bg_png,
        os.path.join(output_dir, f'{prefab_name}_final.png'))
    
    print(f'  {prefab_name}: 完成 → {final}')
    return final

7. 替代方案分析

7.1 spine-python 运行时

Esoteric Software 官方 Python 运行时可以从 .skel + .atlas 加载并渲染到 Pygame Surface。但需要精确匹配 Spine 4.0.55 版本且 PMA 支持不完善,不推荐。

7.2 直接修改角色 skeleton 将 bg 嵌入

理论上可以把 bg bone/slot/attachment 直接写入角色的 .skel 文件(先将二进制转 JSON,再修改 JSON,再转回二进制)。但这需要 spine-python 运行时进行格式转换,且容易引入版本兼容问题。当前的"分离渲染 + 合成"方案更稳健。

7.3 工具选型总结

需求 工具 原因
解析 Unity bundle UnityPy 成熟的 Python bundle 解密+解析库
提取 Arts manifest AssetStudio CLI 批量导出 MonoBehaviour → JSON
渲染 Spine SpineViewer CLI 支持 2.1-4.2, PMA, 超分, 多格式
合成图片 Pillow 简单可靠, (0,0) 对齐即可

8. 附录

8.1 依赖安装

pip install UnityPy pycryptodome Pillow
# SpineViewer 需要 .NET 8.0 Desktop Runtime
# AssetStudio CLI 预编译在 tools/ 目录

8.2 常见问题

Q: bg 工程用 Spine 3.8.75,但角色是 4.0.55,版本不一致会出问题吗?
A: 不会。两者独立渲染,SpineViewer 内建了所有版本的运行时,自动适配。

Q: --margin 设多少合适?
A: 至少等于 max(|bg_offset_x|, |bg_offset_y|) + 200。对于典型偏移 (-37, 471),margin=500 足够。

Q: 如何获取精确的 PPU?
A: AssetStudio 导出 Texture2D 时会保留原始宽高。用 texture_width / sprite_size.x 计算。

现在ai好用了,让ai写了个详细的教程