弃用警告
本文对于当前版本的 vkguide 已经过时,仅适用于旧版 Vkguide。由于新教程已转向从 GLTF 加载,因此不再需要本文,从而消除了对此的需求。
加载资源
在本教程中,我们一直在直接加载 .obj 和 .png 文件。这样做的问题是我们依赖于第三方库(stb_image 和 tinyobj),并且以这种方式加载格式效率很低。您可能已经看到,当您处于调试模式时,引擎需要几秒钟才能加载。
在真正的引擎上,您不会在运行时加载这些格式。相反,您将这些格式转换为引擎特定的快速加载格式,然后加载该格式。
优点是显着的
- 加载速度快几个数量级。
- 可以根据引擎的需求自定义格式。
- 格式可以以平台特定的方式存储,以便在运行时可以将批量数据直接复制到 VRAM 中
- 在主运行时引擎中只需处理一种格式,将所有其他格式都转换为该格式。
- 像 Assimp 或 FBX SDK 这样的重型资源加载第三方库被排除在主可执行文件之外。
- 大大简化了加载的运行时逻辑
另一方面,有一些缺点需要考虑。
- 需要开发和维护自定义格式。
- 需要开发和维护一个转换器,将经典格式转换为自定义格式。
- 自定义格式将无法被任何工具读取。
总的来说,将资源系统实现到您的引擎中是一个好主意,除非您不关心加载时间或可执行文件膨胀。虽然维护自定义格式是额外的工作,但与拥有一个可以非常快速加载场景的引擎所带来的生产力提高相比,这算不了什么。
资源系统架构
对于资源系统,我们将在多个目标和库中实现它。我们将有一个可执行目标,它将是一个转换器。此转换器将查看一个文件夹,并尝试将其中每个文件转换为优化的格式。另一个目标将是用于资源加载的共享库。我们将在此处实现核心保存/加载逻辑。主运行时引擎和资源转换器都将链接到此共享库。这样可以更轻松地保持所有内容同步。
引擎和转换器都将链接 AssetLib。转换器将链接所有不同的资源格式库。我们将使引擎完全不受这些格式的影响。资源系统与主 Vulkan 引擎 100% 分离,因此可以随意将其直接复制粘贴到您的引擎或项目中。
库
我们将向项目中添加 2 个库。第一个是 LZ4 压缩库,我们将使用它来压缩二进制 blob。第二个是 nhlomans Json 库,它是现代 Cpp 最容易使用的 json 库,而且非常好用。
- LZ4: https://github.com/lz4/lz4
- Json: https://github.com/nlohmann/json
资源格式
我们将保持文件格式非常简单,同时仍然非常快速地加载。我们从 glTF 格式的工作方式中获得启发,并使用一个二进制文件,其中包含 json 标头以及二进制 blob。json 标头将包含有关对象的元数据和信息,二进制 blob 将包含对象的原始数据,例如纹理的像素。
对于二进制 blob,我们还将使用 LZ4 格式对其进行压缩。LZ4 是一种针对速度优化的压缩编解码器,如果我们使用它,则很可能比从磁盘读取未压缩数据更快。但我们还将在格式中保留其他压缩系统的空间,因为像新主机这样的东西已经在硬件中实现了 .zip 或 kraken 压缩。
所有资源都将遵循 json 元数据 + 压缩二进制 blob 的格式。这样代码将是统一的并且更易于处理。
纹理资源
对于纹理,我们将它们的数据存储在 json 中。宽度、深度、格式等。实际的像素数据将进入 blob,进行压缩。未压缩的像素数据将与 Vulkan 期望在缓冲区中找到的数据完全相同,然后复制到 VkImage 中。
加载纹理资源时,我们将首先像往常一样加载文件,但我们将保持纹理处于未压缩状态。当将纹理数据复制到 VkBuffer 以进行上传时,我们将动态解压缩数据。
对于压缩,我们仍然只使用 LZ4,但在稍后,我们还可以将像素数据放入 BCn 格式,这也将为我们节省 VRAM。
网格资源
网格资源将类似于纹理,只是我们不会将未压缩的 blob 直接复制到 GPU 中。相反,压缩的 blob 只是一个 Vertex 结构的数组,具有多种不同类型的顶点。加载时,我们会将这些 Vertex 结构转换为引擎使用的任何结构。对于某些顶点格式,可以使它们与渲染器同步,并直接复制到缓冲区中,而无需转换。
代码
我们将从编写核心 Asset 逻辑开始。这将处理通用的“json + blob”结构,然后我们将其处理为不同类型的资源。在资源库的代码中,有 2 件非常重要的事情要记住。我们要绝对确保标头不包含诸如 nlohman json 或 lz4 之类的库。这些库将被编译到 AssetLib 库中,并且对于 Vulkan 引擎本身是不可见的。此外,我们将使 api 完全无状态。函数调用之间不保留类或状态。这样我们就可以确保可以从多个线程安全地使用该库。
对于“核心”资源文件的 api,我们只需要 2 个函数,以及 AssetFile 结构。
namespace assets {
struct AssetFile {
char type[4];
int version;
std::string json;
std::vector<char> binaryBlob;
};
bool save_binaryfile(const char* path, const AssetFile& file);
bool load_binaryfile(const char* path, AssetFile& outputFile);
}
这将是我们核心资源系统的整个公共 API。纹理和网格进入单独的文件,并在 AssetFile 结构之上工作。
加载文件会将 json 复制到字符串中,并将压缩的二进制 blob 复制到资源内部的向量中。请非常小心这一点,因为 AssetFile 结构将非常大,因此不要将它们存储在任何地方以避免 RAM 使用量膨胀。
让我们首先展示将资源保存到磁盘的代码
bool assets::save_binaryfile(const char* path, const assets::AssetFile& file)
{
std::ofstream outfile;
outfile.open(path, std::ios::binary | std::ios::out);
outfile.write(file.type, 4);
uint32_t version = file.version;
//version
outfile.write((const char*)&version, sizeof(uint32_t));
//json length
uint32_t length = file.json.size();
outfile.write((const char*)&length, sizeof(uint32_t));
//blob length
uint32_t bloblength = file.binaryBlob.size();
outfile.write((const char*)&bloblength, sizeof(uint32_t));
//json stream
outfile.write(file.json.data(), length);
//blob data
outfile.write(file.binaryBlob.data(), file.binaryBlob.size());
outfile.close();
return true;
}
我们将只进行纯二进制文件。
我们首先存储 4 个字符,它们是资源类型。对于纹理,这将是 TEXI
,对于网格,这将是 MESH
。我们可以使用它来轻松识别我们正在加载的二进制文件是网格还是纹理,还是某种错误的格式。
接下来,我们存储版本,这是一个 uint32 数字。如果我们在某个时候更改格式,我们可以使用它,以便在尝试加载它时给出错误。始终对文件格式进行版本控制至关重要。
在版本之后,我们存储 json 字符串的长度(以字节为单位),然后存储二进制 blob 的长度。
写入标头后,现在我们只需将 json 和 blob 直接写入文件即可。我们首先写入整个 json 字符串,然后直接写入二进制 blob。
要从磁盘加载文件,我们执行相同的操作,但顺序相反。
bool assets::load_binaryfile(const char* path, assets::AssetFile& outputFile)
{
std::ifstream infile;
infile.open(path, std::ios::binary);
if (!infile.is_open()) return false;
//move file cursor to beginning
infile.seekg(0);
infile.read(outputFile.type, 4);
infile.read((char*)&outputFile.version, sizeof(uint32_t));
uint32_t jsonlen = 0;
infile.read((char*)&jsonlen, sizeof(uint32_t));
uint32_t bloblen = 0;
infile.read((char*)&bloblen, sizeof(uint32_t));
outputFile.json.resize(jsonlen);
infile.read(outputFile.json.data(), jsonlen);
outputFile.binaryBlob.resize(bloblen);
infile.read(outputFile.binaryBlob.data(), bloblen);
return true;
}
我们读取版本、类型以及 json 和 blob 的长度。然后我们使用标头中存储的长度读取 json 字符串,对 blob 执行相同的操作。
我们目前尚未进行任何版本或类型检查。如果找不到文件,这些函数将只返回 false,但没有错误检查。
这就是资源文件本身所需要的一切。它只是将 json 字符串和二进制删除转储到打包文件中,非常简单。
更有趣的事情是处理纹理和网格。我们将只演练纹理保存/加载逻辑,因为网格的工作方式相同,您可以查看代码库以了解差异。
纹理
namespace assets {
enum class TextureFormat : uint32_t
{
Unknown = 0,
RGBA8
};
struct TextureInfo {
uint64_t textureSize;
TextureFormat textureFormat;
CompressionMode compressionMode;
uint32_t pixelsize[3];
std::string originalFile;
};
//parses the texture metadata from an asset file
TextureInfo read_texture_info(AssetFile* file);
void unpack_texture(TextureInfo* info, const char* sourcebuffer, size_t sourceSize, char* destination);
AssetFile pack_texture(TextureInfo* info, void* pixelData);
}
与主资源文件一样,我们将使 API 非常小且无状态。read_texture_info
将解析文件中的元数据 json,并将其转换为 TextureInfo 结构,这是纹理的主要数据。
unpack_texture
将与纹理信息以及像素数据的二进制 blob 一起工作,并将纹理解压缩到目标缓冲区中。目标缓冲区必须足够大,否则会溢出,这一点非常重要。这旨在用于将 blob 直接解压缩到缓冲区中。
//prepare asset file and texture info
//example of how to load the data
AllocatedBuffer stagingBuffer = engine.create_buffer(textureInfo.textureSize, VK_BUFFER_USAGE_TRANSFER_SRC_BIT, VMA_MEMORY_USAGE_CPU_ONLY);
void* data;
vmaMapMemory(engine._allocator, stagingBuffer._allocation, &data);
assets::unpack_texture(&textureInfo, file.binaryBlob.data(), file.binaryBlob.size(), (char*)data);
vmaUnmapMemory(engine._allocator, stagingBuffer._allocation);
//upload texture to gpu with commands
让我们看一下将纹理打包到 AssetFile 中的实现
assets::AssetFile assets::pack_texture(assets::TextureInfo* info, void* pixelData)
{
nlohmann::json texture_metadata;
texture_metadata["format"] = "RGBA8";
texture_metadata["width"] = info->pixelsize[0];
texture_metadata["height"] = info->pixelsize[1];
texture_metadata["buffer_size"] = info->textureSize;
texture_metadata["original_file"] = info->originalFile;
//core file header
AssetFile file;
file.type[0] = 'T';
file.type[1] = 'E';
file.type[2] = 'X';
file.type[3] = 'I';
file.version = 1;
//compress buffer into blob
int compressStaging = LZ4_compressBound(info->textureSize);
file.binaryBlob.resize(compressStaging);
int compressedSize = LZ4_compress_default((const char*)pixelData, file.binaryBlob.data(), info->textureSize, compressStaging);
file.binaryBlob.resize(compressedSize);
texture_metadata["compression"] = "LZ4";
std::string stringified = texture_metadata.dump();
file.json = stringified;
return file;
}
我们首先从 Info 结构转换为 json。然后我们通过编码 TEXI
类型 + 版本 1 来准备资源文件的标头。然后我们将像素数据压缩到资源的二进制 blob 中,最后将 json 转换为字符串并将其存储到资源中。
lz4 库的使用方式如下。
std::vector<char> blob;
//find the maximum data needed for the compression
int compressStaging = LZ4_compressBound(sourceSize);
//make sure the blob storage has enough size for the maximum
blob.resize(compressStaging);
//this is like a memcpy, except it compresses the data and returns the compressed size
int compressedSize = LZ4_compress_default((const char*)source, blob.data(), sourceSize, compressStaging);
//we can now resize the blob down to the final compressed size.
blob.resize(compressedSize);
对于解包代码,它看起来像这样。
首先我们需要从资源中获取纹理信息
assets::TextureInfo assets::read_texture_info(AssetFile* file)
{
TextureInfo info;
nlohmann::json texture_metadata = nlohmann::json::parse(file->json);
std::string formatString = texture_metadata["format"];
info.textureFormat = parse_format(formatString.c_str());
std::string compressionString = texture_metadata["compression"];
info.compressionMode = parse_compression(compressionString.c_str());
info.pixelsize[0] = texture_metadata["width"];
info.pixelsize[1] = texture_metadata["height"];
info.textureSize = texture_metadata["buffer_size"];
info.originalFile = texture_metadata["original_file"];
return info;
}
这与上面的内容几乎相同,只是镜像了。我们从 json 中读取数据并将其存储到 TextureInfo 中。有了纹理信息,我们现在可以调用 unpack_texture
void assets::unpack_texture(TextureInfo* info, const char* sourcebuffer, size_t sourceSize, char* destination)
{
if (info->compressionMode == CompressionMode::LZ4) {
LZ4_decompress_safe(sourcebuffer, destination, sourceSize, info->textureSize);
}
else {
memcpy(destination, sourcebuffer, sourceSize);
}
}
解包时,我们只需直接解压缩到目标目标中。如果文件未压缩,那么我们只需直接 memcpy 即可。
这就是纹理资源逻辑的全部内容。对于网格逻辑,它的工作方式类似,因此您可以查看代码。
- 核心资源系统:https://github.com/vblanco20-1/vulkan-guide/blob/engine/assetlib/asset_loader.cpp
- 纹理加载器:https://github.com/vblanco20-1/vulkan-guide/blob/engine/assetlib/texture_asset.cpp
- 网格加载器:https://github.com/vblanco20-1/vulkan-guide/blob/engine/assetlib/mesh_asset.cpp
转换器
在实现了纹理保存/加载逻辑之后,我们现在可以查看转换器本身。转换器将是一个与普通引擎分离的可执行文件。这是为了隔离它将使用的所有库,以便它们不会污染引擎。这也意味着我们可以以发布模式编译它,并使其非常快速地转换所有内容,然后我们从我们的调试模式引擎加载它。
转换器的整个代码库都在这里 https://github.com/vblanco20-1/vulkan-guide/blob/engine/asset-baker/asset_main.cpp
转换器的使用方法是为其提供要处理的目标文件夹。然后它将遍历文件夹中的文件并尝试转换它们。
fs::path path{ argv[1] };
fs::path directory = path;
std::cout << "loading asset directory at " << directory << std::endl;
for (auto& p : fs::directory_iterator(directory))
{
std::cout << "File: " << p;
if (p.path().extension() == ".png") {
std::cout << "found a texture" << std::endl;
auto newpath = p.path();
newpath.replace_extension(".tx");
convert_image(p.path(), newpath);
}
if (p.path().extension() == ".obj") {
std::cout << "found a mesh" << std::endl;
auto newpath = p.path();
newpath.replace_extension(".mesh");
convert_mesh(p.path(), newpath);
}
}
}
我们正在使用 Cpp17 Filesystem 库。这样我们可以轻松地迭代文件夹内容。如果您无法使用 Cpp17,那么您将必须使用平台 API 来实现。
我们首先将 argv[1] 存储到 filesystem::path 中。然后我们将使用 directory_iterator
迭代该路径下的内容。对于文件夹中的每个文件,我们检查扩展名是否为 .png
,并将其转换为纹理。如果它是 .obj
文件,我们将其转换为网格。纹理将存储为 .tx
,网格将存储为 .mesh
。
转换函数内部的代码是从我们过去在主引擎中使用的代码复制粘贴而来的。它的工作方式完全相同,只是我们不是将其加载到 GPU 的缓冲区中,而是使用资源库将其存储到磁盘。我们将查看纹理的,因为同样,网格是相似的,您可以查看实现。
bool convert_image(const fs::path& input, const fs::path& output)
{
int texWidth, texHeight, texChannels;
stbi_uc* pixels = stbi_load(input.u8string().c_str(), &texWidth, &texHeight, &texChannels, STBI_rgb_alpha);
if (!pixels) {
std::cout << "Failed to load texture file " << input << std::endl;
return false;
}
int texture_size = texWidth * texHeight * 4;
TextureInfo texinfo;
texinfo.textureSize = texture_size;
texinfo.pixelsize[0] = texWidth;
texinfo.pixelsize[1] = texHeight;
texinfo.textureFormat = TextureFormat::RGBA8;
texinfo.originalFile = input.string();
assets::AssetFile newImage = assets::pack_texture(&texinfo, pixels);
stbi_image_free(pixels);
save_binaryfile(output.string().c_str(), newImage);
return true;
}
我们将使用 stb_image 库来加载像素数据和纹理格式/大小。然后我们使用纹理的大小和格式信息填充 TextureInfo 结构。
然后我们将纹理的像素数据打包到一个新的 AssetFile 中,并保存资源文件。
这样,我们现在可以将 .png
文件转换为可直接加载的像素格式。由此带来的速度提升非常大,因为 .png
格式加载速度非常慢。
加载
文件转换完成后,最后一步是能够将此类资源加载到引擎中。
bool vkutil::load_image_from_asset(VulkanEngine& engine, const char* filename, AllocatedImage& outImage)
{
assets::AssetFile file;
bool loaded = assets::load_binaryfile(filename, file);
if (!loaded) {
std::cout << "Error when loading image\n";
return false;
}
assets::TextureInfo textureInfo = assets::read_texture_info(&file);
VkDeviceSize imageSize = textureInfo.textureSize;
VkFormat image_format;
switch (textureInfo.textureFormat) {
case assets::TextureFormat::RGBA8:
image_format = VK_FORMAT_R8G8B8A8_UNORM;
break;
default:
return false;
}
AllocatedBuffer stagingBuffer = engine.create_buffer(imageSize, VK_BUFFER_USAGE_TRANSFER_SRC_BIT, VMA_MEMORY_USAGE_CPU_ONLY);
void* data;
vmaMapMemory(engine._allocator, stagingBuffer._allocation, &data);
assets::unpack_texture(&textureInfo, file.binaryBlob.data(), file.binaryBlob.size(), (char*)data);
vmaUnmapMemory(engine._allocator, stagingBuffer._allocation);
outImage = upload_image(textureInfo.pixelsize[0], textureInfo.pixelsize[1], image_format, engine, stagingBuffer);
vmaDestroyBuffer(engine._allocator, stagingBuffer._buffer, stagingBuffer._allocation);
return true;
}
这就是我们需要从文件中加载的所有内容。我们首先加载资源文件本身,然后解析纹理信息,然后将纹理像素直接解包到将用于上传纹理的暂存缓冲区中。您可以在 https://github.com/vblanco20-1/vulkan-guide/blob/engine/extra-engine/vk_textures.cpp 上找到此代码
对于网格数据,请查看源代码。总体流程与纹理加载相同,但数据略有不同。此资源系统非常容易扩展,因为您可以继续创建新的资源类型,并向网格/纹理资源本身添加更多格式。
在这种系统中可以做的另一件事是使其能够加载纯 json 文件,这些文件是文本格式的。它们可以指向另一个文件来获取 blob 数据,或者只是不包含 blob 数据。这样它们将更容易被人编辑。