Skip to content

[Bug] 桌宠的Spine组件之间出现低Alpha缝合线的调查报告 #76

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
isHarryh opened this issue Sep 7, 2024 · 3 comments
Closed
Labels
Bug Something isn't working

Comments

@isHarryh
Copy link
Owner

isHarryh commented Sep 7, 2024

问题描述

在部分桌宠模型(大部分是较早实装的模型)渲染后,在它们的 Spine 组件之间(例如头发、眼眶和膝盖处)均可见一条缝合线。如图所示:

image

现象调查

1. 成因分析

因为缝合线后面的背景可以穿透过来,所以推断缝合线是由低 Alpha 值导致的。为便于观察具体的 Alpha 值分布,进行 Alpha 单通道上色后,结果如图所示:

image

可见,缝合线的 Alpha 值主要介于 0.5-0.95 之间,以 0.75-0.95 居多。

2. 检查 Spine 源图片

Spine 源图片采用解包后图片的 RGB-A 通道合并而生成,理论上不可能出现错误。观察 Spine 源图片,未见组件边缘的颜色和 Alpha 值异常,如图所示:

image

3. 检查低 Alpha 色块的 RGB 值

在片段着色器进行一些处理,使得 Alpha 大于 0.5 的部分变为完全不透明,以此检查这些部分的 RGB 值。

gl_FragColor.a = gl_FragColor.a < 0.5 ? gl_FragColor.a * 2.0 : 1.0;

得到的渲染结果如图所示:

image

可见,缝合线的 RGB 值表现为灰度而非彩色。处理后,缝合线以灰度模式填充。

但是在游戏中,尤其是模型的膝盖部分,并不是灰度填充,而是与相邻组件具有相似颜色。

解决建议

综上所述,高度怀疑游戏中使用了特殊的着色器,消除了缝合线。

为了缓解桌宠的缝合线问题,给出两种解决方案:

  1. 直接将缝合线的 Alpha 值提高(正如“调查过程.3”所提及的那样),将缝合线填充为灰度。
  2. 编写额外的着色器代码,将缝合线填充为相邻组件的颜色(暂时采用)。
@isHarryh isHarryh added the Bug Something isn't working label Sep 7, 2024
@isHarryh isHarryh pinned this issue Oct 7, 2024
@isHarryh isHarryh unpinned this issue Nov 10, 2024
@isHarryh
Copy link
Owner Author

isHarryh commented Feb 11, 2025

深入调查

1. 契机

经用户提醒,发现在早期的 ArkPets 版本中,该问题不存在。进一步的测试表明,此问题引入于 v2.4.2 的一个 Bug 修复

ArkPets Commit 5e15d00

在这个 Commit 中,我们将 SkeletonRenderer 的预乘 Alpha(Premultiplied Alpha,PMA)选项改成了 false

/* Pre-multiplied alpha shouldn't be applied to models released in Arknights 2.1.41 or later,
otherwise you may get a corrupted rendering result. */
renderer.setPremultipliedAlpha(false);

这是因为,我们发现在《明日方舟》游戏版本 v2.1.41 及之后,部分模型的渲染会出现异常的颜色条纹(以下简称”伪影“),而关闭 PMA 就可以解决这个问题。

未曾想到,这一针对”部分模型“的修复,反而导致”另一部分“模型的渲染出现了缝合线。

以下列举了几个会出现伪影的模型:

模型名称 示例图片
薄绿 时代/XXX 基建
388_mint_epoque#30
薄绿 时代/XXX 基建
焰影苇草 时代/XXX 基建
1020_reed2_epoque#30
焰影苇草 时代/XXX 基建
余 默认服装 基建
2026_yu
余 默认服装 基建

2. 禁用 PMA 为什么会导致缝合线的产生

禁用 PMA 时,BlendFunctionsrcColor 参数是 GL_SRC_ALPHA,具体的运作方式可以描述为:

FinalColor.rgb = (Incoming.rgb * Incoming.a) + (Existing.rgb * (1 - Incoming.a));

启用 PMA 时,BlendFunctionsrcColor 参数是 GL_ONE,具体的运作方式可以描述为:

FinalColor.rgb = (Incoming.rgb) + (Existing.rgb * (1 - Incoming.a));

由此可知,”另一部分“模型会产生缝合线的直接原因是:这类模型本应该启用 PMA,但是却没有启用,由此导致 Incoming.rgb 被多乘以了一次 Incoming.a,所以边缘处的透明度显著降低,进而产生缝合线。

参考资料: https://zh.esotericsoftware.com/forum/d/3132-premultiplied-alpha-guide/2

3. 如何判断是否应该启用 PMA

本应禁用 PMA 却启用了 PMA 时,”部分模型“会出现伪影;本应启用 PMA 却禁用了 PMA 时,”另一部分“模型会出现缝合线。那么,判断一个模型是否需要启用 PMA 的依据是什么?

经过对原始 AB 文件的研究,可以发现,需要启用 PMA 的模型,它的纹理图的 RGB 通道和 Alpha 通道是分离的。这是因为《明日方舟》早期采用的纹理是由 ETC 编码的,而 ETC 编码模式不支持 Alpha 通道,所以需要两张图片(一张 RGB 和一张 Alpha)来进行存储。

由于在生成 RGB 通道的图片时,已经经过一次预乘 Alpha 操作了。那么,合并两张通道图(再堆叠一层 Alpha 通道)之后,我们得到的图片就是预乘 Alpha 的图片。因此,这一类纹理图片就需要启用 PMA 渲染。

而不需要启用 PMA 的模型,它的纹理图没有分离 Alpha 通道,而是直接使用 RGBA 格式存储的。这是因为《明日方舟》后来使用了 ASTC 编码来存储纹理。由于 ASTC 编码和之前的 ETC 编码相比,可以存储 Alpha 通道,因此不需要做通道分离的操作。

这样一来,和之前的”做过通道分离的图片“相比,没有做过通道分离的图片缺少了一次预乘 Alpha 的计算。因此,这一类纹理图就不需要启用 PMA 渲染。

4. 启用 PMA 为什么会产生伪影

首先,没有做通道分离的这类纹理,它还采用了一个渗色技术(Bleeding)——通过将非透明像素的颜色扩展到透明像素区域,防止图像在变换和采样时出现颜色污染。

下图展示了一个使用渗色技术的纹理图(RGB 通道):

Image

启用 PMA 时,纹理图的 RGB 值本该乘以一次 Alpha,但是却没有乘。这就会导致本该是”透明“区域,在经过变换和采样后,不可见像素的颜色影响到了可见像素的颜色。于是,伪影产生了。

为了验证”采用渗色技术的需要禁用 PMA 的纹理“会产生伪影,我们可以人工地给一个”没有渗色的需要启用 PMA 的纹理“进行渗色。

使用如下代码进行人工渗色(参考自这个 C++ 项目的实现):

import numpy as np
from PIL import Image

def apply_bleeding(image: np.ndarray, remove_alpha: bool = False):
    height, width, _ = image.shape
    output = np.copy(image).astype(np.uint8)

    status = np.zeros((height, width), dtype=np.uint8)
    OPAQUE, TIGHT, LOOSE = 0, 1, 2
    OFFSETS = [(-1, -1), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)]

    pending = []
    pending_next = []
    for y in range(height):
        for x in range(width):
            if output[y, x, 3] == 0:  # Not opaque
                is_loose = True
                for dy, dx in OFFSETS:
                    sy, sx = y + dy, x + dx
                    if 0 <= sy < height and 0 <= sx < width:
                        if output[sy, sx, 3] > 0:
                            is_loose = False
                            break
                if is_loose:  # No opaque neighbor (loose)
                    status[y, x] = LOOSE
                else:  # Has opaque neighbor (tight)
                    status[y, x] = TIGHT
                    pending.append((y, x))

    while pending:
        for y, x in pending:
            count = 0
            r, g, b = 0, 0, 0

            for dy, dx in OFFSETS:
                sy, sx = y + dy, x + dx
                if 0 <= sy < height and 0 <= sx < width:
                    if status[sy, sx] == OPAQUE:
                        r += output[sy, sx, 0]
                        g += output[sy, sx, 1]
                        b += output[sy, sx, 2]
                        count += 1

            if count > 0:  # Do color bleeding
                output[y, x, 0] = r // count
                output[y, x, 1] = g // count
                output[y, x, 2] = b // count
                status[y, x] = OPAQUE

                for dy, dx in OFFSETS:
                    sy, sx = y + dy, x + dx
                    if 0 <= sy < height and 0 <= sx < width:
                        if status[sy, sx] == LOOSE:
                            pending_next.append((sy, sx))
                            status[sy, sx] = TIGHT
            else:
                pending_next.append((y, x))

        swap = pending
        pending = pending_next
        pending_next = swap
        pending_next.clear()

    if remove_alpha:
        for y in range(height):
            for x in range(width):
                output[y, x, 3] = 255

    return output

if __name__ == '__main__':
    path = "build_char_377_gdglow_summer#12.png"
    img = np.asarray(Image.open(path))
    out = apply_bleeding(img)
    img = Image.fromarray(out)
    img.show()
    # img.save("temp.png")

渗色后的纹理,在启用 PMA 时,确实会产生同样的伪影,如图所示:

Image

使用到的纹理图片文件:

@isHarryh
Copy link
Owner Author

isHarryh commented Feb 11, 2025

解决方法

为了修复这一问题,使得既没有伪影出现、也没有缝合线出现,有两类解决方法:

  1. 让渲染器判断某个模型是否需要 PMA(通过文件直接判断?在数据集里面手动增加一个字段?)。
  2. 把”不需要 PMA 的纹理“全部变成”需要 PMA 的纹理“,或者反过来(在解包和导出的时候转化?在渲染之前转化?)。

经过反复测试,目前拟定解决方法如下:

在解包器中,如果导出模型时,遇到了没有进行通道分离的纹理,那么对该纹理进行一次额外的预乘 Alpha

使用的代码如下:

def apply_premultiplied_alpha(rgba:"Image.Image"):
    """Multiplies the RGB channels with the alpha channel.
    Useful when handling non-PMA Spine textures.

    :param rgba: Instance of RGBA image;
    :returns: A new image instance;
    :rtype: Image;
    """
    img_rgba:Image.Image = rgba.convert('RGBA')
    data = np.array(img_rgba, dtype=np.float32)
    data[:, :, :3] *= data[:, :, 3:] / 255.0
    data_int = np.clip(data, 0, 255).astype(np.uint8)
    return Image.fromarray(data_int, "RGBA")

通过这种方式,我们就能把所有没有启用 PMA 的图片都强制启用 PMA。然后,我们在渲染时,全部启用 PMA 就行了。

使用到的纹理图片文件:

目前 ArkModels 模型库采用的解包器是 ArkUnpacker

经过 RenderDoc 工具的测试,发现非 PMA 图片的渗色已经被完全移除,如图所示:

Image

”部分模型“和”另一部分“模型共存的效果如图所示:

Image

可以发现,伪影和缝合线已经被完美解决了。

收尾工作

将于 ArkPets v3.7 发布此修复( 08b1bdb )。之前基于 Shader 的缝合线补偿功能已被移除( 344713c )。

注意,用户必须更新模型库来获得新解包的(强制启用 PMA 的)纹理图,否则仍会出现伪影。软件和模型库的兼容性参见附表

备注:
PRTS.wiki 那边采用的模型渲染器,在 prts-widgets Commit 0d0d3c0 里面覆写了渲染函数。覆写后的结果是等价于禁用 PMA 的。所以截至目前,PRTS.wiki 的模型渲染也会出现缝合线。已向 PRTS.wiki 提交议题,参见 prts-widgets #192

@isHarryh
Copy link
Owner Author

附表

表 1:PMA 对渲染表现的影响

模型是否是 PMA 模型 是否启用了 PMA 渲染 渲染表现
√ 是 √ 是 正常
√ 是 × 否 有缝合线问题
× 否 √ 是 有伪影问题
× 否 × 否 正常

表2:各个桌宠版本的 PMA 情况

桌宠版本 是否启用了 PMA 渲染
<=2.4.1 √ 开启
>=2.4.2 且 <=3.6.0 × 关闭
>=3.7.0 √ 开启

表3:各个模型库版本的 PMA 情况

模型库版本 是否包含 PMA 模型 是否包含非 PMA 模型
2023 年或更早 √ 是 × 否
2024 年到 2025 年 2 月 √ 是 √ 是
2025 年 3 月或更晚 √ 是 × 否

表4:桌宠版本与模型库版本的兼容性

桌宠版本 模型库版本 缝合线 伪影
<=2.4.1 或 >=3.7.0 2023 年或更早 - -
<=2.4.1 或 >=3.7.0 2024 年到 2025 年 2 月 - +
<=2.4.1 或 >=3.7.0 2025 年 3 月或更晚 - -
>=2.4.2 且 <=3.6.0 2023 年或更早 + -
>=2.4.2 且 <=3.6.0 2024 年到 2025 年 2 月 + -
>=2.4.2 且 <=3.6.0 2025 年 3 月或更晚 ++ -

表注: - 表示没有问题,+ 表示部分模型会发生问题,++ 表示所有模型都会发生问题。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Bug Something isn't working
Projects
None yet
Development

No branches or pull requests

1 participant