图集诊断 · spine_yadianna · Spine 3.8.99

黑边不是混合模式设错了
是这份图集自己前后矛盾

半透明区已经预乘过,全透明区却带着一层实色 rgb(76,105,113) —— 图集被压成 PNG-8 时,所有透明像素并进了同一个调色板条目,而它的 RGB 没归零。 这两件事同时成立,于是不管把 premultipliedAlpha 打到哪一边, 画面都有毛病 —— 一边是黑边,一边是青灰泛光。

三种组合,自己点开看

播的是 idle,20 帧 15fps,原生分辨率。三格唯一的差别只有图集和混合函数。 ②的青灰泛光切纯白背景最刺眼,①的黑边切纱裙 4×看得最清楚。

背景 取景

不能只改代码

两步必须一起做,只做一步都不行。
  • 只改代码(不动图集)= 上面第②格:黑边没了,换成整块青灰泛光糊在背景上
  • 只处理图集(不改代码)= 半透明区还是被乘两次,黑边照旧
因为这份图集同时有两个毛病,一步只治一个。

怎么做

  1. 程序 · 第 1 步

    把调色板里那个透明条目的 RGB 清零

    这 48 页是 PNG-8 索引色(256 色调色板),所有全透明像素共用同一个 调色板条目,而它的 RGB 是 (76,105,113) 没被归零。 所以不用重编码整张图 —— 改调色板里的 3 个字节 + 重算该 chunk 的 CRC 就行: 像素数据一个字节不动,文件大小不变,画质零影响。

    # 只改 PLTE 里 alpha=0 那些条目的 RGB。文件大小前后完全一致。
    import struct, zlib
    d = bytearray(open(src, "rb").read())
    i, plte, trns = 8, None, None
    while i < len(d):
        ln = struct.unpack(">I", d[i:i+4])[0]; typ = bytes(d[i+4:i+8])
        if   typ == b"PLTE": plte = (i+8, ln)
        elif typ == b"tRNS": trns = bytes(d[i+8:i+8+ln])
        elif typ == b"IDAT": break
        i += 12 + ln
    for idx, a in enumerate(trns):
        if a == 0:
            o = plte[0] + idx*3
            d[o] = d[o+1] = d[o+2] = 0
    crc = zlib.crc32(b"PLTE" + bytes(d[plte[0]:plte[0]+plte[1]])) & 0xffffffff
    struct.pack_into(">I", d, plte[0]+plte[1], crc)
    open(dst, "wb").write(bytes(d))

    别用 PIL 读进来再存 —— 那会把 PNG-8 重编码成 RGBA32, 48 页从 8.5 MB 涨到 21.3 MB(2.5 倍)。 也别用 canvas 2D 的 getImageData,它内部预乘会把数据改坏。

  2. 程序 · 第 2 步

    runtime 切到预乘那一边

    // 官方 spine-webgl
    skeletonRenderer.premultipliedAlpha = true;
    
    // Cocos Creator:sp.Skeleton 组件面板勾上 Premultiplied Alpha
    // Pixi + pixi-spine:baseTexture.alphaMode = PIXI.ALPHA_MODES.PMA
    //   (v8 写法:texture.source.alphaMode = 'premultiplied-alpha')
    // Laya / Egret:Templet 或材质上的同名属性
  3. 打包 · 根治

    问题出在 PNG 压缩那一步,不是 Spine 导出

    Spine 导出的图集本身是对的(RGBA32、预乘、透明区 RGB=0)。 是导出之后有人把 48 页压成了 PNG-8(pngquant / TinyPNG 那类), 量化器把所有全透明像素并成一个调色板条目,而那个条目的 RGB 没被归零 —— 三张图实测都是同一个值 (76,105,113),说明是同一套工具同一组参数。

    所以根治点在压缩流水线:压完之后补一步上面那个改调色板的操作, 做成脚本挂在流水线末尾即可。压缩本身该留着 —— 不压的话 48 页要 21.3 MB, 对 H5 是实打实的下载成本。

美术这边

出图的时候能避免吗

这一个在出图环节挡不住 —— 它是图集导出之后、压成 PNG-8 那一步产生的。 但下面两件出图时能管住,尤其是 AI 生成动画抠背景那一步,最容易留下同类问题:

交付前想自检,跑这几行就够:

# 透明区残留颜色自检:输出 0 才算干净
from PIL import Image, ImageChops
im = Image.open("frame.png").convert("RGBA")
r, g, b, a = im.split()
mask = a.point(lambda v: 255 if v == 0 else 0)
print(max(ImageChops.multiply(c, mask).getextrema()[1] for c in (r, g, b)))