前言
立方体贴图是一种特殊的贴图,它可以用来实现天空盒技术。
天空盒可以看作一个很大的立方体,相机被包裹在这个立方体之内,只会渲染立方体内部的表面。由于天空盒很大,所以无论相机在哪个位置观察,都会感觉天空盒和自己的距离一直保持不变。
场景中的物体还可以向天空盒发起采样,然后把采样到的颜色显示在自己的表面,就可以形成像上图一样的反射效果。
本篇将分析SaschaWillems的texturecubemap案例的代码实现思路,然后在自己的小引擎上也尝试复现。
资源准备
下图是样例中自带的用于展示天空盒的Cube,它是gltf格式的。可以看到,它的面光影的显示存在一定的问题,可能是法线设定得不太正确。同时,模型的rotation不为0,我认为这是不规范的。把模型导入blender之后,发现该模型在blender中的边长正好是10m,因此我决定自己在blender中导出一个更加规范的10m边长的天空盒供展示使用。
本项目的天空盒是ktx格式的,使用ktx库读取资源。
ktx库的代码可以在这个路径下找到,这是一个只有源码的库,所以我需要把它的头文件和部分源文件都加入我自己的项目。
头文件加入项目的方式很简单,有cmake基础的人都知道该如何做。这里注意,需要把include和lib这两个文件夹都加入项目的头文件查找目录,因为二者里面都有.h文件。
至于源文件,哪些源文件需要加入项目呢?通过查询原程序的工程结构,我发现,是以下几个文件:
files
{
//项目自带的文件
"src/**.h",
"src/**.cpp",
"vendor/stb_image/**.h",
"vendor/stb_image/**.cpp",
"vendor/glm/glm/**.hpp",
"vendor/glm/glm/**.inl",
"vendor/ImGuizmo/ImGuizmo.h",
"vendor/ImGuizmo/ImGuizmo.cpp",
......
//ktx库中需要加入项目的.c文件
"vendor/ktx/lib/texture.c",
"vendor/ktx/lib/hashlist.c",
"vendor/ktx/lib/checkheader.c",
"vendor/ktx/lib/swap.c",
"vendor/ktx/lib/memstream.c",
"vendor/ktx/lib/filestream.c",
}
我的项目使用premake管理,所以以上代码是premake的。但cmake管理项目也是一样的思路,不需要过多介绍。
以下链接介绍了一种将hdr格式转为ktx的工具,还介绍了一个可以下载hdr格式天空盒的网站。
cmgen制作天空盒
代码分析
总体流程
以下是该项目准备阶段的代码。准备阶段的主要工作是导入模型、贴图,准备UniformBuffer、描述符集、渲染管线等资源。在最后一步buildCommandBuffers()函数中,程序把所有绘制天空盒和模型有关的命令都提前放在了CommandBuffer中。由于本项目不需要频繁更改CommandBuffer,所以在绘制阶段,只需要不断地把之前准备阶段CommandBuffer中记录好的命令反复提交给GPU渲染即可。
当然,如果某些重要参数发生了更改,还是需要重建CommandBuffer的。
void prepare()
{
VulkanExampleBase::prepare();
loadAssets();
prepareUniformBuffers();
setupDescriptorSetLayout();
preparePipelines();
setupDescriptorPool();
setupDescriptorSets();
buildCommandBuffers();
prepared = true;
}
以下是该项目绘制阶段和UI更新阶段的代码。这两个函数每一帧调用一次,UI更新的函数会监听一些配置参数的改变,在合适的时候重建CommandBuffer。
void draw()
{
VulkanExampleBase::prepareFrame();
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &drawCmdBuffers[currentBuffer];
VK_CHECK_RESULT(vkQueueSubmit(queue, 1, &submitInfo, VK_NULL_HANDLE));
VulkanExampleBase::submitFrame();
}
virtual void OnUpdateUIOverlay(vks::UIOverlay *overlay)
{
if (overlay->header("Settings")) {
if (overlay->sliderFloat("LOD bias", &uboVS.lodBias, 0.0f, (float)cubeMap.mipLevels)) {
updateUniformBuffers();
}
if (overlay->comboBox("Object type", &models.objectIndex, objectNames)) {
buildCommandBuffers();
}
if (overlay->checkBox("Skybox", &displaySkybox)) {
buildCommandBuffers();
}
}
}
模型和贴图加载
void loadAssets()
{
uint32_t glTFLoadingFlags = vkglTF::FileLoadingFlags::PreTransformVertices | vkglTF::FileLoadingFlags::FlipY;
// Skybox
models.skybox.loadFromFile(getAssetPath() + "models/cube.gltf", vulkanDevice, queue, glTFLoadingFlags);
// Objects
std::vector<std::string> filenames = { "sphere.gltf", "teapot.gltf", "torusknot.gltf", "venus.gltf" };
objectNames = { "Sphere", "Teapot", "Torusknot", "Venus" };
models.objects.resize(filenames.size());
for (size_t i = 0; i < filenames.size(); i++) {
models.objects[i].loadFromFile(getAssetPath() + "models/" + filenames[i], vulkanDevice, queue, glTFLoadingFlags);
}
// Cubemap texture
const bool forceLinearTiling = false;
loadCubemap(getAssetPath() + "textures/cubemap_yokohama_rgba.ktx", VK_FORMAT_R8G8B8A8_UNORM, forceLinearTiling);
}
以上代码为加载gltf格式模型的代码和加载CubeMap的代码。加载gltf格式模型的代码并不是我要关注的重点,因为我的项目是使用TinyObjLoader加载obj格式模型的。接下来重点分析以下加载CubeMap的代码。这段代码比较长,不过其实大部分都是配置的信息,并不难以理解,我将会在注释中标出要重点关注的内容。
if (!vks::tools::fileExists(filename)) {
vks::tools::exitFatal("Could not load texture from " + filename + "\n\nMake sure the assets submodule has been checked out and is up-to-date.", -1);
}
//1.从给定的文件名中加载ktx格式的贴图
result = ktxTexture_CreateFromNamedFile(filename.c_str(), KTX_TEXTURE_CREATE_LOAD_IMAGE_DATA_BIT, &ktxTexture);
assert(result == KTX_SUCCESS);
//2.从ktx纹理中获取宽度,高度,mipLevel等参数,以及获取纹理中的数据,在后面的步骤中会把这些数据拷贝到staging buffer,然后从staging buffer中拷贝到vulkan的image中
// Get properties required for using and upload texture data from the ktx texture object
cubeMap.width = ktxTexture->baseWidth;
cubeMap.height = ktxTexture->baseHeight;
cubeMap.mipLevels = ktxTexture->numLevels;
ktx_uint8_t *ktxTextureData = ktxTexture_GetData(ktxTexture);
ktx_size_t ktxTextureSize = ktxTexture_GetSize(ktxTexture);
VkMemoryAllocateInfo memAllocInfo = vks::initializers::memoryAllocateInfo();
VkMemoryRequirements memReqs;
//3.创建stagingBuffer,准备写入贴图数据
// Create a host-visible staging buffer that contains the raw image data
VkBuffer stagingBuffer;
VkDeviceMemory stagingMemory;
VkBufferCreateInfo bufferCreateInfo = vks::initializers::bufferCreateInfo();
bufferCreateInfo.size = ktxTextureSize;
// This buffer is used as a transfer source for the buffer copy
bufferCreateInfo.usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT;
bufferCreateInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
VK_CHECK_RESULT(vkCreateBuffer(device, &bufferCreateInfo, nullptr, &stagingBuffer));
// Get memory requirements for the staging buffer (alignment, memory type bits)
vkGetBufferMemoryRequirements(device, stagingBuffer, &memReqs);
memAllocInfo.allocationSize = memReqs.size;
// Get memory type index for a host visible buffer
memAllocInfo.memoryTypeIndex = vulkanDevice->getMemoryType(memReqs.memoryTypeBits, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);
VK_CHECK_RESULT(vkAllocateMemory(device, &memAllocInfo, nullptr, &stagingMemory));
VK_CHECK_RESULT(vkBindBufferMemory(device, stagingBuffer, stagingMemory, 0));
//4.准备把贴图数据送入staging buffer。该过程是一个cpu上的过程,所以不需要command buffer来完成
// Copy texture data into staging buffer
uint8_t *data;
VK_CHECK_RESULT(vkMapMemory(device, stagingMemory, 0, memReqs.size, 0, (void **)&data));
memcpy(data, ktxTextureData, ktxTextureSize);
vkUnmapMemory(device, stagingMemory);
//5.创建真正存储贴图数据的Image对象
// Create optimal tiled target image
VkImageCreateInfo imageCreateInfo = vks::initializers::imageCreateInfo();
imageCreateInfo.imageType = VK_IMAGE_TYPE_2D;
imageCreateInfo.format = format;
imageCreateInfo.mipLevels = cubeMap.mipLevels;//这里需要和之前的mipLevel保持一致
imageCreateInfo.samples = VK_SAMPLE_COUNT_1_BIT;
imageCreateInfo.tiling = VK_IMAGE_TILING_OPTIMAL;
imageCreateInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
imageCreateInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
imageCreateInfo.extent = { cubeMap.width, cubeMap.height, 1 };//这里需要和之前的宽度,高度保持一致
imageCreateInfo.usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT;
// Cube faces count as array layers in Vulkan
imageCreateInfo.arrayLayers = 6;
// This flag is required for cube map images
imageCreateInfo.flags = VK_IMAGE_CREATE_CUBE_COMPATIBLE_BIT;
VK_CHECK_RESULT(vkCreateImage(device, &imageCreateInfo, nullptr, &cubeMap.image));
vkGetImageMemoryRequirements(device, cubeMap.image, &memReqs);
memAllocInfo.allocationSize = memReqs.size;
memAllocInfo.memoryTypeIndex = vulkanDevice->getMemoryType(memReqs.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
VK_CHECK_RESULT(vkAllocateMemory(device, &memAllocInfo, nullptr, &cubeMap.deviceMemory));
VK_CHECK_RESULT(vkBindImageMemory(device, cubeMap.image, cubeMap.deviceMemory, 0));
//6.创建一个command buffer,把staging buffer中的数据拷贝到image中
VkCommandBuffer copyCmd = vulkanDevice->createCommandBuffer(VK_COMMAND_BUFFER_LEVEL_PRIMARY, true);
// Setup buffer copy regions for each face including all of its miplevels
std::vector<VkBufferImageCopy> bufferCopyRegions;
uint32_t offset = 0;
for (uint32_t face = 0; face < 6; face++)
{
for (uint32_t level = 0; level < cubeMap.mipLevels; level++)
{
// Calculate offset into staging buffer for the current mip level and face
ktx_size_t offset;
KTX_error_code ret = ktxTexture_GetImageOffset(ktxTexture, level, 0, face, &offset);
assert(ret == KTX_SUCCESS);
VkBufferImageCopy bufferCopyRegion = {};
bufferCopyRegion.imageSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
bufferCopyRegion.imageSubresource.mipLevel = level;
bufferCopyRegion.imageSubresource.baseArrayLayer = face;
bufferCopyRegion.imageSubresource.layerCount = 1;
bufferCopyRegion.imageExtent.width = ktxTexture->baseWidth >> level;//每一个新的mipLevel,宽度和高度都是上一level的一半
bufferCopyRegion.imageExtent.height = ktxTexture->baseHeight >> level;
bufferCopyRegion.imageExtent.depth = 1;
bufferCopyRegion.bufferOffset = offset;
bufferCopyRegions.push_back(bufferCopyRegion);
}
}
//7.Image进行状态转换,先转换为拷贝数据的状态,在拷贝完数据之后,再转换为可以被shader读取的状态
// Image barrier for optimal image (target)
// Set initial layout for all array layers (faces) of the optimal (target) tiled texture
VkImageSubresourceRange subresourceRange = {};
subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
subresourceRange.baseMipLevel = 0;
subresourceRange.levelCount = cubeMap.mipLevels;
subresourceRange.layerCount = 6;
vks::tools::setImageLayout(
copyCmd,
cubeMap.image,
VK_IMAGE_LAYOUT_UNDEFINED,
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
subresourceRange);
// Copy the cube map faces from the staging buffer to the optimal tiled image
vkCmdCopyBufferToImage(
copyCmd,
stagingBuffer,
cubeMap.image,
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
static_cast<uint32_t>(bufferCopyRegions.size()),
bufferCopyRegions.data()
);
// Change texture image layout to shader read after all faces have been copied
cubeMap.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
vks::tools::setImageLayout(
copyCmd,
cubeMap.image,
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
cubeMap.imageLayout,
subresourceRange);
vulkanDevice->flushCommandBuffer(copyCmd, queue, true);
//8.创建Sampler对象和Image View对象
// Create sampler
VkSamplerCreateInfo sampler = vks::initializers::samplerCreateInfo();
sampler.magFilter = VK_FILTER_LINEAR;
sampler.minFilter = VK_FILTER_LINEAR;
sampler.mipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR;
sampler.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE;
sampler.addressModeV = sampler.addressModeU;
sampler.addressModeW = sampler.addressModeU;
sampler.mipLodBias = 0.0f;
sampler.compareOp = VK_COMPARE_OP_NEVER;
sampler.minLod = 0.0f;
sampler.maxLod = static_cast<float>(cubeMap.mipLevels);
sampler.borderColor = VK_BORDER_COLOR_FLOAT_OPAQUE_WHITE;
sampler.maxAnisotropy = 1.0f;
if (vulkanDevice->features.samplerAnisotropy)
{
sampler.maxAnisotropy = vulkanDevice->properties.limits.maxSamplerAnisotropy;
sampler.anisotropyEnable = VK_TRUE;
}
VK_CHECK_RESULT(vkCreateSampler(device, &sampler, nullptr, &cubeMap.sampler));
// Create image view
VkImageViewCreateInfo view = vks::initializers::imageViewCreateInfo();
// Cube map view type
view.viewType = VK_IMAGE_VIEW_TYPE_CUBE;//指定是cube类型的Image View
view.format = format;
view.subresourceRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 };
// 6 array layers (faces)
view.subresourceRange.layerCount = 6;
// Set number of mip levels
view.subresourceRange.levelCount = cubeMap.mipLevels;
view.image = cubeMap.image;
VK_CHECK_RESULT(vkCreateImageView(device, &view, nullptr, &cubeMap.view));
//9.清除没有用的中间资源
// Clean up staging resources
vkFreeMemory(device, stagingMemory, nullptr);
vkDestroyBuffer(device, stagingBuffer, nullptr);
ktxTexture_Destroy(ktxTexture);
整个过程和Vulkan加载普通纹理图像的过程非常相似。这一切都加载完毕后,我们就可以把CubeMap的描述符写入需要的描述符集中,然后绑定描述符集,就可以在Shader中访问了。
渲染资源
渲染天空盒所需要的资源很简单,只有一个ViewProjection矩阵和一个立方体贴图用于采样。
ViewProjection矩阵需要把移动部分清零,这是因为,无论相机移动到什么位置,场景都应该看起来始终在天空盒的中心,就像现实中的天空一样,无论如何尝试靠近它,都始终会感觉它距离我们很远。
天空盒的渲染管线配置和默认的管线配置基本一样,只需要注意两点:
- 天空盒需要正面剔除而不是背面剔除;
- 天空盒关闭深度测试和深度写入。
绘制流程
天空盒需要绘制在场景中所有物体的前面,且不参与阴影贴图的生成。先绑定天空盒的管线,绘制天空盒,然后再正常绘制其他物体即可。
个人尝试
这是我把天空盒渲染部分整合到自己引擎之后的渲染尝试。渲染效果和原程序基本相同。