在Galgame引擎Artemis Engine立绘合成的适配工作基本完成后,紧接着开始Galgame引擎Favorite View Point System(FVP)立绘合成的适配工作。

探索引擎FVP的立绘合成方式

Galgame引擎Favorite View Point System(FVP)为日本会社FAVORITE自己开发的一款Galgame引擎,所以一般的只有FAVORITE出品的Galgame才会使用该引擎。

一般的,引擎FVP的人物立绘文件都被封进名为graph_bs.bin的资源包中,这里以星辰恋曲的白色永恒为例,解包后人物立绘文件分布如下:

Galgame Image Cover Tools开发日记三——引擎FVP的适配1.jpg

我们可以发现,解包出来的文件都为.hzc文件,这是引擎FVP里一种特别的文件格式,专门用来存储图像资源的。

还是老样子,把其中一个文件使用16进制编辑器打开,发现文件头部分全是乱糟糟的16进制数据。在没有弄清楚文件头那一大堆数据如何读取之前,根本无从下手……

这时,我们就需要请教我们伟大的GitHub老师了,他给我们提供了一个项目——Garbro,也就是Galagme基本万能的解包工具,里面就有相关.hzc文件的解析源代码,使用C#语言编写:

//! \file       ArcHZC.cs
//! \date       Wed Dec 09 17:04:23 2015
//! \brief      Favorite View Point multi-frame image format.
//
// Copyright (C) 2015 by morkt
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to
// deal in the Software without restriction, including without limitation the
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
// IN THE SOFTWARE.
//

using System;
using System.Collections.Generic;
using System.ComponentModel.Composition;
using System.IO;
using GameRes.Utility;
using GameRes.Compression;

namespace GameRes.Formats.FVP
{
    internal class HzcArchive : ArcFile
    {
        public readonly HzcMetaData ImageInfo;

        public HzcArchive (ArcView arc, ArchiveFormat impl, ICollection<Entry> dir, HzcMetaData info)
            : base (arc, impl, dir)
        {
            ImageInfo = info;
        }
    }

    [Export(typeof(ArchiveFormat))]
    public class HzcOpener : ArchiveFormat
    {
        public override string         Tag { get { return "HZC/MULTI"; } }
        public override string Description { get { return "Favorite View Point multi-frame image"; } }
        public override uint     Signature { get { return 0x31637A68; } } // 'HZC1'
        public override bool  IsHierarchic { get { return false; } }
        public override bool      CanWrite { get { return false; } }

        public HzcOpener ()
        {
            Extensions = new string[] { "hzc" };
        }

        static readonly Lazy<ImageFormat> Hzc = new Lazy<ImageFormat> (() => ImageFormat.FindByTag ("HZC"));

        public override ArcFile TryOpen (ArcView file)
        {
            uint header_size = file.View.ReadUInt32 (8);
            HzcMetaData image_info;
            using (var header = file.CreateStream (0, 0xC+header_size))
            {
                image_info = Hzc.Value.ReadMetaData (header) as HzcMetaData;
                if (null == image_info)
                    return null;
            }
            int count = file.View.ReadInt32 (0x20);
            if (0 == count)
                count = 1;
            string base_name = Path.GetFileNameWithoutExtension (file.Name);
            int frame_size = image_info.UnpackedSize / count;
            var dir = new List<Entry> (count);
            for (int i = 0; i < count; ++i)
            {
                var entry = new Entry {
                    Name = string.Format ("{0}#{1:D3}", base_name, i),
                    Type = "image",
                    Offset = frame_size * i,
                    Size = (uint)frame_size,
                };
                dir.Add (entry);
            }
            return new HzcArchive (file, this, dir, image_info);
        }

        public override Stream OpenEntry (ArcFile arc, Entry entry)
        {
            var hzc = (HzcArchive)arc;
            using (var input = arc.File.CreateStream (0xC+hzc.ImageInfo.HeaderSize))
            using (var z = new ZLibStream (input, CompressionMode.Decompress))
            {
                uint frame_size = entry.Size;
                var pixels = new byte[frame_size];
                uint offset = 0;
                for (;;)
                {
                    if (pixels.Length != z.Read (pixels, 0, pixels.Length))
                        throw new EndOfStreamException();
                    if (offset >= entry.Offset)
                        break;
                    offset += frame_size;
                }
                return new BinMemoryStream (pixels, entry.Name);
            }
        }

        public override IImageDecoder OpenImage (ArcFile arc, Entry entry)
        {
            var hzc = (HzcArchive)arc;
            var input = arc.File.CreateStream (0xC+hzc.ImageInfo.HeaderSize);
            try
            {
                return new HzcDecoder (input, hzc.ImageInfo, entry);
            }
            catch
            {
                input.Dispose();
                throw;
            }
        }
    }
}

经过对上面源码的分析后,我们可以很清楚地知道了如何读取文件头那一大堆乱糟糟的16进制数据了:

Galgame Image Cover Tools开发日记三——引擎FVP的适配2.jpg

其中,红色框内2个字节为图像类型数据,1代表身体差分类型(不需要进行图像切割),2代表表情差分类型,需要后面进行图像切割;蓝色框内2个字节代表该图像文件画布的X值;橙色框内2个字节代表该图像文件画布的Y值;紧接着紫色框内2个字节为合成立绘偏移坐标的X值;灰色框内2个字节为合成立绘偏移坐标的Y值;下一行绿色框内4个字节为图像内总共应包含多少图像(对于表情差分来说,也就是包含多少表情差分,进而需要对整个图像进行固定次数的切割。);最后从粉色竖线后,一直到文件数据末尾,就是图像原数据了。

然后,上面源码中的其中一行是这么写的:

using (var z = new ZLibStream (input, CompressionMode.Decompress))

我们发现,源代码使用了ZLibStream函数对图像原数据进行解压操作,所以得到图像原数据后还需要进行Zlib解压操作。

接着,如果该hzc文件为表情差分文件,且根据不切割的输出结果看,表情差分文件实际上就是多个表情差分从上而下堆叠而成的超长图,所以只需要根据前面得到的图像画布X和Y,进行循环切割即可。

综上,根据思路,用Python就可以还原解析.hzc文件的操作了!

这里,为了后面代码块更方便的调用,设定了A、B、C三种模式,分别对应图像文件解析操作、输出表情差分标注列表以及输出偏移坐标:

import zlib
from PIL import Image
###……
def FVPfg(ImagePath,mode,ImageNumber):
    if mode == "A":
        with open(ImagePath,"rb") as f:
            f.seek(18)
            modedata = f.read(2)
            datax = f.read(2)
            datay = f.read(2)
            f.seek(32)
            ImageSum = f.read(4)
            f.seek(44)
            data = f.read()
            f.close()
        datas = zlib.decompress(data) ##zlib解压
        modedatas = int.from_bytes(modedata,"little") #小端模式读取
        dataxs = int.from_bytes(datax,"little") ##图像(画布)的x
        datays = int.from_bytes(datay,"little") ##图像(画布)的y
        ImageSums = int.from_bytes(ImageSum,"little") ##使用小端读取,否则会有问题
        image_size = (dataxs, datays)  # 图像尺寸
        image_mode = 'RGBA'  # 颜色模式
        if modedatas == 1:
            image = Image.frombytes(image_mode, image_size, datas) ##将字节数组处理成为字节流图像
        elif modedatas == 2:
            image = Image.frombytes(image_mode, (dataxs,datays*ImageSums), datas)
            images = image.crop((0,datays*int(ImageNumber),dataxs,datays*(int(ImageNumber) + 1))) ##切割表情差分的长图片
        return image
    elif mode == "B":
        with open(ImagePath,"rb") as f:
            f.seek(32)
            ImageSum = f.read(4)
            f.close()
        ImageSums = int.from_bytes(ImageSum,"little")
        PartImageList = []
        for i in range(0,ImageSums):
            PartImageList.append("{:03d}".format(i))
            i = i + 1
        return PartImageList ##计算hzc包里面的表情差分数量
    elif mode == "C":
        with open(ImagePath,"rb") as f:
            f.seek(24)
            dataxp = f.read(2)
            datayp = f.read(2)
            f.close()
        hzcx = int.from_bytes(dataxp,"little")
        hzcy = int.from_bytes(datayp,"little")
        return hzcx,hzcy ##坐标输出

分析图像资源

Galgame Image Cover Tool在开发之前的基本规划是用户直接导入整个Galgame人物立绘的解包后资源,文件分布都是以默认的方式进行读取。所以需要对解包后资源进行固定形式的检索。

下面是星辰恋曲的白色永恒的解包后立绘文件:

Galgame Image Cover Tools开发日记三——引擎FVP的适配3.jpg

根据分析,引擎FVP人物立绘的解包资源中,所有立绘文件都在同一个文件夹下,无多级文件夹。上面图片中,红色框内为角色名称,蓝色框内为立绘类型,橙色框内为角色服装,紫色框中为立绘尺寸类型,分为没有标注、L和U,根据角色的不同类型也不同,且分辨率(图像质量)按顺序不断增加。它们之间都用下划线分开。

检索图像资源

角色名称的获取

接下来,开始编写角色名称获取代码。

在_tmp.py代码块读取Setting.ini文件后,直接循环检索graph_bs文件夹下的所有文件,使用指定字符串(下划线)分割的方式得到角色名称,以列表形式存储,并传递给主窗口的CharactersCombobox,供用户选择角色:

elif GalEngine == "FVP":
    FilesList = []
    CharactersList = []
    for i in os.listdir(ResourcePath):
        if "CHR" in i:
            FilesList.append(i)
            splitted = i.split("_")
            if splitted[1] not in CharactersList:
                CharactersList.append(splitted[1])

立绘类型的获取

在前面对图像资源的分析中,我们可以知道立绘类型在角色名称之后,所以这里依然指定字符串切割,和角色名称的检索过程基本一致:

elif _tmp.GalEngine == "FVP":
    BaseplateList = []
    CharactersFileList = []
    for i in _tmp.FilesList:
        if CharactersName in i:
            CharactersFileList.append(i.replace(".hzc",""))
            splitted = i.split("_")
             if splitted[2] not in StyleList:
                 StyleList.append(splitted[2])

罗列身体差分

在选择了立绘类型后,接下来就要罗列出身体差分了,也就是人物立绘图像合成的底板,这样就可以和再下一步的表情差分的罗列相对应。

这里为了简化选择过程,索性把角色服装和立绘尺寸类型合成一个项目进行罗列,也就是身体差分:

Galgame Image Cover Tools开发日记三——引擎FVP的适配4.jpg

从中我们可以发现,身体差分图像和角色差分图像文件名称唯一不同的地方就是表情差分文件名后面多了”_表情”字符串,所以还是可以直接根据特殊字符串切割的方式罗列出身体差分:

def StyleCombobox(self,event):
    ###……
    StyleName = event.GetString()
    elif _tmp.GalEngine == "FVP":
        for i in CharactersFileList:
            splitted = i.split("_")
            if StyleName in i:
                if splitted[3] not in BaseplateList:
                    BaseplateList.append(splitted[3])

罗列表情差分

最后,就是要罗列出表情差分了。

通过前面对表情差分文件的分析后,我们得知,表情差分超长图切割完后,并没有固定的名称,所以我们需要为每张表情差分使用数字进行标注,并罗列出来,也就是前面FVPfg()函数中的模式B所对应的操作。

这样,在主窗口代码块中,直接调用FVPfg()函数获取表情差分的标注列表即可,顺便获取偏移坐标,并将底板(身体差分)提前解析出来,提高后面图像的预览速度:

def BaseplateCombobox(self,event):
    ###……
    BaseplateName = event.GetString()
    ###……
    elif _tmp.GalEngine == "FVP":
        PartImagePath = _tmp.ResourcePath + "\\CHR_" + CharactersName + "_" + StyleName + "_" + BaseplateName + "_表情.hzc"
        FVPxy = FVP.FVPfg(PartImagePath,"C",0)
        PartImagechoice.SetItems(FVP.FVPfg(PartImagePath,"B",0))
        BaseplatePath = _tmp.ResourcePath + "\\CHR_" + CharactersName + "_" + StyleName + "_" + BaseplateName + ".hzc"
        image2 = FVP.FVPfg(BaseplatePath,"A",0)

合成图像

最后,当用户选择完表情差分对应的标注后,根据标注调用FVPfg()函数进行指定位置切割表情差分超长图,并根据偏移坐标合成出图像:

def PartImageCombobox(self,event):
    ###……
    PartImageName = event.GetString()
    ###……
    elif _tmp.GalEngine == "FVP":
        PartImagePath = _tmp.ResourcePath + "\\CHR_" + CharactersName + "_" + StyleName + "_" + BaseplateName + "_表情.hzc"
        image1 = FVP.FVPfg(PartImagePath,"A",PartImageName)
        image2.alpha_composite(image1,FVPxy)

但,当我们兴高采烈的去查看合成后的图像时,看到了这样古怪的图像:

Galgame Image Cover Tools开发日记三——引擎FVP的适配5.png

这个图像颜色通道是错误的,证明转换方式出了问题……

在之前,我编写Artemis批量合成人物立绘的小工具时,曾经遇到这样类似的问题,但那种情况只需要不使用RGBA模式去读取图像就可以了,但在前面使用Image.frombytes()函数将Zlib解压后的图像数据转换为字节流图像时,必须指定图像模式,且最后合成出来的图像是带有Alpha通道的,所以这里只能使用RGBA模式了,也就导致了这种奇怪图像的出现……

最后,我还是去请教了GitHub老师,他给我推了fvp-tool这个项目,没想到竟然是使用Python编写的解析hzc文件的工具。

下面是该项目里面解析hzc文件的源代码:

#!/usr/bin/python3
import argparse
import sys
import zlib
from PIL import Image
from open_ext import open_ext

HZC1_MAGIC = b'hzc1'
NVSG_MAGIC = b'NVSG'

def swap_channels(img):
    channels = list(img.split())
    channels[0], channels[2] = channels[2], channels[0]
    return Image.merge(img.mode, channels)

def convert_to_png(input_file, output_file):
    if input_file.read(len(HZC1_MAGIC)) != HZC1_MAGIC:
        raise RuntimeError('Not a NVSG file')
    uncompressed_size = input_file.read_u32_le()
    nvsg_header_size = input_file.read_u32_le()
    if input_file.read(len(NVSG_MAGIC)) != NVSG_MAGIC:
        raise RuntimeError('Not a NVSG file')

    unk = []
    unk.append(input_file.read_u16_le()) #always 256?
    format = input_file.read_u16_le()
    width = input_file.read_u16_le()
    height = input_file.read_u16_le()
    x = input_file.read_u16_le() #not sure if coordinates, but seems close
    y = input_file.read_u16_le()
    unk.append(input_file.read_u16_le())
    unk.append(input_file.read_u16_le())
    image_count = input_file.read_u32_le()
    unk.append(input_file.read_u32_le())
    unk.append(input_file.read_u32_le())

    data = input_file.read_until_end()
    data = zlib.decompress(data)

    if format == 0:
        img = swap_channels(Image.fromstring('RGB', (width, height), data))
    elif format == 1:
        img = swap_channels(Image.fromstring('RGBA', (width, height), data))
    elif format == 2:
        height *= image_count
        img = swap_channels(Image.fromstring('RGBA', (width, height), data))
        print('Make sure to pass --image_count %d when converting back!' % (image_count))
    elif format == 3:
        img = Image.fromstring('L', (width, height), data)
    elif format == 4:
        raise NotImplementedError('1 bit images are not implemented')
    else:
        raise NotImplementedError('Unknown image format')
    img.save(output_file, 'png')
    print('Make sure to pass -x %d -y %d converting back!' % (x, y))

def convert_from_png(input_file, output_file, image_count, x, y):
    img = Image.open(input_file)
    img.load()
    width, height = img.size
    if image_count != 1:
        if img.mode != 'RGBA':
            raise RuntimeError('Image strides must be saved with alpha channel.')
        format = 2
        height = int(height / image_count)
        img = swap_channels(img)
    elif img.mode == 'RGBA':
        format = 1
        img = swap_channels(img)
    elif img.mode == 'RGB':
        format = 0
        img = swap_channels(img)

    data = img.tostring()

    output_file.write(HZC1_MAGIC)
    output_file.write_u32_le(len(data))
    output_file.write_u32_le(0x20)
    output_file.write(NVSG_MAGIC)
    output_file.write_u16_le(256)
    output_file.write_u16_le(format)
    output_file.write_u16_le(width)
    output_file.write_u16_le(height)
    output_file.write_u16_le(x)
    output_file.write_u16_le(y)
    output_file.write_u16_le(0)
    output_file.write_u16_le(0)
    output_file.write_u32_le(image_count)
    output_file.write_u32_le(0)
    output_file.write_u32_le(0)
    output_file.write(zlib.compress(data))

def main():
    parser = argparse.ArgumentParser(description='Converts NVSG to PNG and vice versa')
    parser.add_argument('--image_count', type=int, default=1, help='used in encoding character avatars')
    parser.add_argument('-x', nargs='?', type=int, default=0, help='used in encoding')
    parser.add_argument('-y', nargs='?', type=int, default=0, help='used in encoding')
    parser.add_argument('infile', nargs='?', type=open_ext.ArgParser('rb'), default=sys.stdin)
    parser.add_argument('outfile', nargs='?', type=open_ext.ArgParser('wb'), default=sys.stdout)
    group = parser.add_mutually_exclusive_group(required=True)
    group.add_argument('--decode', action='store_true', help='converts NVSG to PNG')
    group.add_argument('--encode', action='store_false', help='converts PNG to NVSG')
    result = parser.parse_args()
    if result.decode:
        convert_to_png(result.infile, result.outfile)
    else:
        convert_from_png(result.infile, result.outfile, result.image_count, result.x, result.y)

if __name__ == '__main__':
    main()

说实话,我也是没怎么看懂这位大佬写的代码,不过他在转换图像的时候,使用了swap_channels()这个函数:

def swap_channels(img):
    channels = list(img.split())
    channels[0], channels[2] = channels[2], channels[0]
    return Image.merge(img.mode, channels)

根据后期的研究,我才知道,hzc原图像数据是BGRA模式的图像,但因为Python的PIL库没有这个图像模式,所以这个函数就是将R和B的通道进行了对调,这样转换出来的图像颜色通道才是正确的。

经过了不断地摸索,到这里,引擎FVP的适配代码编写工作终于结束了🎉🎉🎉

Galgame Image Cover Tools开发日记三——引擎FVP的适配6.jpg

根据后期测试,该立绘合成方式对FAVORITE的其他作品——五彩斑斓的世界和樱花萌放同样适用,所以基本可以认为该立绘合成方式对使用引擎FVP的所有Galgame都适用!✨✨✨