Featured image of post CrossFire Asset 2: DTX to PNG

CrossFire Asset 2: DTX to PNG

问题描述

这篇帖子是 CF 素材提取的第二期,主要介绍如何将穿越火线中的 DTX 文件转换为 PNG 文件。

完整代码的链接:Python代码

DTX 文件是穿越火线中的纹理文件格式,为了方便预览和在其他地方使用,需要转换到 PNG 格式。网络上能找到用于转换的 Adobe Photoshop CS4 插件,但是用起来很不方便,只能一个个打开转换,因此本篇帖子介绍使用 Python 脚本批量转换 DTX 文件到 PNG。

注意,这个程序只能做 DTX 到 PNG 的转换,不做反向的转换。如果有这方面的需要,请自行使用 Photoshop 插件来完成。

预处理

CF 从 REZ 当中提取出来的 DTX 文件,都经过 LZMA 算法的压缩,因此使用 Python 的 lzma 库,来做文件读取的操作。

为了兼容性考虑,这里先用文件的头部 4 字节来判断文件是否被压缩,如果是压缩过的文件,则使用 lzma 库来读取文件,否则直接读取文件。

if open(path, "rb").read(4) == b"\x5D\x00\x00\x00":
    self.fStream = lzma.open(path, "rb")
else:
    self.fStream = open(path, "rb")

读取文件头

DTX 文件的头部是 164 字节的文件头,具体格式请看代码定义:

class BPPIdent(Enum):
    BPP_8P = 0  # 8 bit palettized
    BPP_8 = 1  # 8 bit RGB
    BPP_16 = 2
    BPP_32 = 3
    BPP_S3TC_DXT1 = 4
    BPP_S3TC_DXT3 = 5
    BPP_S3TC_DXT5 = 6
    BPP_32P = 7  # this was added for true color palette support
    BPP_24 = 8

class DtxHeader:
    # Accessors.
    def __init__(self, hdr: bytes):
        # Define the structure format
        # Note: 'I' for uint32, 'i' for int32, 'H' for uint16, 's' for string
        # The union is not directly represented in struct format, so we handle it separately
        struct_format = 'IiHHHHii12s128s'

        # Unpack the data
        unpacked_data = struct.unpack(struct_format, hdr)

        # Extract the values
        self.m_ResType, \
        self.m_Version, \
        self.m_BaseWidth, \
        self.m_BaseHeight, \
        self.m_nMipmaps, \
        self.m_nSections, \
        self.m_IFlags, \
        self.m_UserFlags, \
        self.m_Extra, \
        self.m_CommandString = unpacked_data

        # Convert the extra data to a list of uint8
        self.m_Extra = list(self.m_Extra)

        def get_bpp_ident(self):
            return self.m_Extra[2] if self.m_Extra[2] != 0 else BPPIdent.BPP_32

这当中会用到的是:

  • m_BaseWidth:纹理的宽度
  • m_BaseHeight:纹理的高度
  • get_bpp_ident:这是最重要的,用来判断纹理的格式
  • m_nMipmaps:纹理的 mipmap 数量(大于一的话,会有 1/2 1/4 … 的多级缩略图, 我们只提取第一个最高分辨率的)

根据类型分别解析像素数据

BPP_32

BPP_32 是最简单的格式,直接读取像素数据,然后写入到 PNG 文件当中即可。注意,DTX 文件中的 BPP_32 格式,是 BGRA 格式,需要转换成 RGBA 格式。

def _read_bpp_32(self):
    for x in range(self.height):
        for y in range(self.width):
            b, g, r, a = struct.unpack('<BBBB', self.fStream.read(4))
            self._pixels[x, y] = [r, g, b, a]

BPP_S3TC_DXT1

DXTn 的这三种格式,请参考S3_Texture_Compression

BPP_S3TC_DXT1 是一种压缩格式,又称 BC1 (Block Compressed 1),每个 block 代表 4x4 个像素。

读取颜色值

首先我们需要处理颜色的压缩,DXT1 使用 RGB565 格式来存储颜色,我们需要将其转换为 RGBA 格式,透明度为 255(不透明)。

def rgb565_to_rgba(rgb565):
    r = ((rgb565 >> 11) & 0x1F) << 3  # Extract and scale red component (5 bits to 8)
    g = ((rgb565 >> 5) & 0x3F) << 2   # Extract and scale green component (6 bits to 8)
    b = (rgb565 & 0x1F) << 3          # Extract and scale blue component (5 bits to 8)
    return np.array((r, g, b, 255))  # Alpha is always 255 for DXT1

读取数据块

接下来根据 DXT1 的 BC1 格式,读取每个 block 的数据,然后解压出每个像素的颜色值。

每个数据块 8 字节(64 位),前 4 字节是颜色 0 和颜色 1 各 16 位,后 4 字节是 4x4 像素的索引值,每个像素 2 位。 根据颜色 0 和颜色 1 的值,有两种差值方法,可以计算出中间的 2 个颜色值,共 4 个颜色,然后根据每个像素的索引值,解压出每个像素的颜色值。

def decompress_bc1_block(block:bytes, alpha:bool=True):
    # First 2+2 bytes: color0 and color1 (16-bit RGB565)
    c0, c1 = struct.unpack('<HH', block[0:4])
    # Decode colors into RGBA format
    colors = [rgb565_to_rgba(c0), rgb565_to_rgba(c1)]
    # Intermediate colors
    if c0 > c1 or not alpha:
        colors.append((colors[0] * 2 + colors[1]) / 3)
        colors.append((colors[0] + colors[1] * 2) / 3)
    else:
        colors.append((colors[0] + colors[1]) / 2)
        colors.append(np.array([0, 0, 0, 0]))

    # Next 4 bytes: pixel indices (2 bits per pixel for 4x4 block)
    indices = struct.unpack('<L', block[4:8])[0]
    pixel_data = []
    for bit_offset in range(0, 32, 2):
        pixel_index = (indices >> bit_offset) & 0x03
        pixel_data.append(colors[pixel_index])

    return np.array(pixel_data, dtype=np.uint8).reshape((4, 4, 4))

拼装完整的图像

按块复制粘贴到完整的图像中即可。

def _read_bpp_s3tc_dxt1(self):
    block_size = 8  # Each BC1 block is 8 bytes
    block_dim = 4 # Each BC1 block is 4x4 pixels
    for block_y in range(0, self.height, block_dim):
        for block_x in range(0, self.width, block_dim):
            # Calculate the offset of the current block in the texture data
            block = self.fStream.read(block_size)
            # Decompress the block
            self._pixels[
                block_y: block_y + block_dim,
                block_x: block_x + block_dim
            ] = decompress_bc1_block(block)

BPP_S3TC_DXT3

BPP_S3TC_DXT3 是一种压缩格式,又称 BC2 (Block Compressed 2),每个 block 代表 4x4 个像素。每个 block 包含 2 部分,首先是一个 4x4 的 alpha 透明度值,每个像素对应 4 位,然后是 8 字节的 BC1 数据块。

读取数据块

接下来根据 DXT3 的 BC2 格式,读取每个 block 的数据,然后解压出每个像素的颜色值。

前 8 个字节,每 4 位代表一个像素的透明度值。

接下来是 8 字节的 BC1 数据块,解压出每个像素的颜色值。然后合并透明度和颜色值,得到每个像素的 RGBA 值。

def decompress_bc2_block(block:bytes):
    # BC2 block contains the same color data as BC1, but with an additional 8-byte alpha block
    alpha_block, color_block = block[0:8], block[8:16]

    # 8 bytes (4 bits per pixel for 4x4 block)
    alpha_bits = struct.unpack("<Q", alpha_block)[0]
    alpha_data =  [((alpha_bits >> i) & 0x0F) << 4 for i in range(0, 64, 4)]

    # Apply alpha data to color block
    pixel_data = decompress_bc1_block(color_block, alpha=False)
    pixel_data[:, :, 3] = np.array(alpha_data, dtype=np.uint8).reshape((4, 4))
    return pixel_data

拼装完整的图像

按块复制粘贴到完整的图像中即可。

def _read_bpp_s3tc_dxt3(self):
    block_size = 16  # Each BC2 block is 8 bytes
    block_dim = 4 # Each BC2 block is 4x4 pixels
    for block_y in range(0, self.height, block_dim):
        for block_x in range(0, self.width, block_dim):
            # Calculate the offset of the current block in the texture data
            block = self.fStream.read(block_size)
            # Decompress the block
            self._pixels[
                block_y: block_y + block_dim,
                block_x: block_x + block_dim
            ] = decompress_bc2_block(block)

BPP_S3TC_DXT5

BPP_S3TC_DXT5 是一种压缩格式,又称 BC3 (Block Compressed 3),每个 block 代表 4x4 个像素。每个 block 包含 2 部分,首先是一个 4x4 的透明度数据块,然后是 8 字节的 BC1 数据块。

读取数据块

接下来根据 DXT5 的 BC3 格式,读取每个 block 的数据,然后解压出每个像素的颜色值。

前 8 个字节使用类似 BC1 的插值索引方案,透明度数据块 8 字节(64 位),前 2 字节是透明度 0 和透明度 1,各 8 位,后 6 字节是 4x4 像素的索引值,每个像素 3 位。 根据透明度 0 和透明度 1 的值,有两种差值方法,可以计算出中间的 6 个透明度值,共 8 个透明度,然后根据每个像素的索引值,解压出每个像素的透明度值。

接下来是 8 字节的 BC1 数据块,解压出每个像素的颜色值。然后合并透明度和颜色值,得到每个像素的 RGBA 值。

def decompress_bc3_block(block:bytes):
    # BC3 block contains the same color data as BC1, but with an additional 8-byte alpha block
    alpha_block, color_block = block[0:8], block[8:16]

    # First 1+1 bytes: alpha1 and alpha2 (8-bit)
    a0, a1 = struct.unpack('<BB', alpha_block[0:2])
    alphas = [a0, a1]
    # Intermediate alphas
    if a0 > a1:
        for i in range(1, 7):
            alphas.append((a0 * (7 - i) + a1 * i) // 7)
    else:
        for i in range(1, 5):
            alphas.append((a0 * (5 - i) + a1 * i) // 5)
        alphas.extend([0, 255])

    # Next 6 bytes: pixel indices (3 bits per pixel for 4x4 block)
    ## Pad with 2 zero bytes to make it 8 bytes
    indices = struct.unpack('<Q', alpha_block[2:8] + b'\x00\x00')[0]
    alpha_data = []
    for bit_offset in range(0, 48, 3):
        pixel_index = (indices >> bit_offset) & 0x07
        alpha_data.append(alphas[pixel_index])

    # Apply alpha data to color block
    pixel_data = decompress_bc1_block(color_block, alpha=False)
    pixel_data[:, :, 3] = np.array(alpha_data, dtype=np.uint8).reshape((4, 4))
    return pixel_data

拼装完整的图像

按块复制粘贴到完整的图像中即可。

def _read_bpp_s3tc_dxt5(self):
    block_size = 16  # Each BC3 block is 8 bytes
    block_dim = 4 # Each BC3 block is 4x4 pixels
    for block_y in range(0, self.height, block_dim):
        for block_x in range(0, self.width, block_dim):
            # Calculate the offset of the current block in the texture data
            block = self.fStream.read(block_size)
            # Decompress the block
            self._pixels[
                block_y: block_y + block_dim,
                block_x: block_x + block_dim
            ] = decompress_bc3_block(block)

其他格式

CF 目前没用到,我也就不写了。

类封装

接下来就是写亿点点代码,把这些东西串起来,封装成一个类。详细代码可下载,我这里只写调用方法:

dtx = DtxImage(filename)
dtx.image.save(filename.rsplit(".", maxsplit=1)[0] + ".png")
Licensed under CC BY-NC-SA 4.0
Viewer Count: