半透明区已经预乘过,全透明区却带着一层实色 rgb(76,105,113) ——
图集被压成 PNG-8 时,所有透明像素并进了同一个调色板条目,而它的 RGB 没归零。
这两件事同时成立,于是不管把 premultipliedAlpha 打到哪一边,
画面都有毛病 —— 一边是黑边,一边是青灰泛光。
播的是 idle,20 帧 15fps,原生分辨率。三格唯一的差别只有图集和混合函数。
②的青灰泛光切纯白背景最刺眼,①的黑边切纱裙 4×看得最清楚。
这 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,它内部预乘会把数据改坏。
// 官方 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 或材质上的同名属性
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)))