我在解包游戏:星落
游戏链接:星落安卓版游戏APK下载
的时候遇到了一个问题
角色立绘的背景文件是单独的一个图片,无法随skel文件一起导入spine
spine.zip (6.3 MB)
↑这是我导出的立绘文件和cardspine_10111_2.prefab的ab包
那么游戏里面是如何将角色与背景组合起来的呢?
我又该如何拼出完整的立绘?
我在解包游戏:星落
游戏链接:星落安卓版游戏APK下载
的时候遇到了一个问题
角色立绘的背景文件是单独的一个图片,无法随skel文件一起导入spine
spine.zip (6.3 MB)
↑这是我导出的立绘文件和cardspine_10111_2.prefab的ab包
那么游戏里面是如何将角色与背景组合起来的呢?
我又该如何拼出完整的立绘?
游戏里应该很简单吧,游戏加载画面都有优先级的,把人物优先级调高人物就在背景前面了就,自己弄的话不知道啊 ![]()
星落星落角色立绘提取_星落wiki|GameKee 你看看,我试了下 确实可以把背景拖进去呢
我篇就是我写的,我现在是想找找精确的匹配关系,比如它们各自的坐标,目前我是靠人眼匹配的
![]()
应该是预制体,如果想要精确的坐标最简单是用unity加载ab包,然后加载预制体直接看transform,理论上直接看文件里也有这些信息,但是我没研究过。
而且,预制体一般就是一个skeleton一个image手动自己拖的,如果不需要特别精确自己拖就行。
里面有transform坐标,组件是spriteRenderer以及skeletonanimation。那个gameobjct应该就是预制体。
感谢,大概了解了
我没懂啊 你搞成了吗 所以坐标怎么写
游戏角色的立绘并非单张图片,而是由多个独立资产层组合而成:
┌──────────────────────────────────────┐
│ cardspine_10111_2_bg.png │ ← 背景图 (1697×1330, 普通PNG)
│ │
│ ┌──────────────────────┐ │
│ │ Spine 骨架动画 │ │ ← 人物主体 (.skel + .atlas + .png)
│ │ (cardspine_10111_2) │ │ Spine 4.0.55 格式
│ └──────────────────────┘ │
│ │
└──────────────────────────────────────┘
.skel 骨骼 + .atlas 图集 + .png 纹理).bundle 文件)内部的 Transform 组件中游戏使用 Unity 引擎,立绘在 Unity 中是一个 prefab(预制体)。prefab 包含两个子对象:
SkeletonAnimation 组件的 GameObject(人物)SpriteRenderer 组件的 GameObject(背景)Unity 引擎负责在运行时将两者按各自的 Transform 坐标叠加渲染。背景作为独立的 SpriteRenderer 而不是 Spine 附件,好处是:
当资源从 bundle 中提取出来后:
本报告的目标:完整、自动化地从 bundle 中提取坐标数据,并实现用 SpineViewer 统一渲染的批量拼合。
所有游戏资源以 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 |
┌──────────────────────────────────────────────────────────────┐
│ 第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) 对齐即可合成 │
└──────────────────────────────────────────────────────────────┘
这是整个流程的核心。我们将逐步理解: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 的原因。
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 即可。
# 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 密钥使用
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。
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
对于 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), # 人物在原点
},
}
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
),
}
采样 290 个角色后,发现三种结构:
{'cardspine_10029_1': {'type': 'mesh_part', 'pos': (0.0, 0.0)}}
→ 无背景,跳过拼接。compute_pixel_offset() 返回 None。
{
'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 的偏移。
{
'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 单独导出。
我们需要将人物 Spine 工程和背景 PNG 合并输出为一张完整的立绘。
为什么不直接用 Python Pillow 合成?
SpineViewer 提供了 Python 无法替代的功能:
--scale 4 → 4 倍超分辨率输出(矢量质量放大)-f Gif、-f Mp4 → 直接导出带透明通道的动画核心思路:将背景 PNG 包装为一个最小合法 Spine 工程,将背景偏移量直接写入 skeleton 的 bone 坐标中。然后人物和背景分别通过 SpineViewer 渲染(使用相同的 --scale 等参数),最后在 Python 中 (0,0) 对齐合成。因为偏移已经内置在 bg 工程的 bone 位置上,SpineViewer 输出的 bg 渲染图天然就和人物对齐。
与之前不同: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 图就天然偏移到了正确位置。
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
"""
对比两种方案:
方案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。
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" 就是这两个值。
tools/SpineViewer/SpineViewerCLI.exe — .NET 8.0 / win-x64, v0.16.19
--scale 参数支持任意倍数超分(矢量质量)--margin 参数保留边缘空白# 导出人物为超分静帧 (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 值保证输出尺寸一致。
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
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
Esoteric Software 官方 Python 运行时可以从 .skel + .atlas 加载并渲染到 Pygame Surface。但需要精确匹配 Spine 4.0.55 版本且 PMA 支持不完善,不推荐。
理论上可以把 bg bone/slot/attachment 直接写入角色的 .skel 文件(先将二进制转 JSON,再修改 JSON,再转回二进制)。但这需要 spine-python 运行时进行格式转换,且容易引入版本兼容问题。当前的"分离渲染 + 合成"方案更稳健。
| 需求 | 工具 | 原因 |
|---|---|---|
| 解析 Unity bundle | UnityPy | 成熟的 Python bundle 解密+解析库 |
| 提取 Arts manifest | AssetStudio CLI | 批量导出 MonoBehaviour → JSON |
| 渲染 Spine | SpineViewer CLI | 支持 2.1-4.2, PMA, 超分, 多格式 |
| 合成图片 | Pillow | 简单可靠, (0,0) 对齐即可 |
pip install UnityPy pycryptodome Pillow
# SpineViewer 需要 .NET 8.0 Desktop Runtime
# AssetStudio CLI 预编译在 tools/ 目录
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写了个详细的教程