前言

最近黑猴很火爆啊,一直都在玩黑猴,周末才得闲把之前做完的天空盒总结一下,先来看一下效果:

output1080vid

实现了立方体贴图和球面环境映射两种天空盒的方式。此外,加入了docking功能,它现在看起来更像个编辑器了。

在本篇文章中,你可以看到:

  • 如何使用Vulkan实现天空盒
  • 一个新的Shader编译流程

内容不多,原理也很好理解。

上一篇文章:点光源的阴影 | 喜多喜多のBlog (kita-blog.vercel.app)

本篇:天空盒,以及新的Shader编译流程 | 喜多喜多のBlog (kita-blog.vercel.app)

上篇文章之后,本篇文章之前做的

大家看上面的结果,可以看出来它是在ImGui界面上绘制了。

这是因为我将原本绘制到Swapchain Image上的场景改成了绘制到自己创建的backbuffer上,这样绘制完成后就可以在ImGui里以图像的形式绘制出来。

更像一个编辑器!

天空盒

实现天空盒的基本想法

天空盒相当于远处的一个“背景”,因此我们可以绘制一个立方体,让它的位置永远在相机空间的原点,它的六个面永远在深度值为1的地方,这样就可以获得一个天空盒了。

实现天空盒的步骤

天空盒可以由立方体贴图或者球面环境映射来实现。

立方体贴图

立方体贴图其实就是上一篇点光源的阴影 | 喜多喜多のBlog (kita-blog.vercel.app)所讲的,但是上一篇实现的相当于是”RenderTexture”,而这次我们需要从本地文件中读取图像到Cubemap里,因此我在资源层的Texture里增加了一个TextureCube来加载立方体贴图。

如何加载的呢?总结一下就是仍然使用Resource层Texture加载,只不过最后让它的ImageLayout变换到TransferSrc而不是ShaderReadOnlyOptimal,然后再使用vkCmdCopyImage将所有六张图片复制到立方体贴图六个地方,最后再将之前加载的六张Texture卸载掉(因为ImageLayout是TransferSrc实际上不能用于Shader读取)。这里那六张贴图相当于”Staging Buffer”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// Texture.cpp
// 下面的代码经过了简化
void TextureCube::Load()
{
TStaticArray<Texture*, 6> textures;
Texture* left = Texture::Create("Left.jpg", vk::ImageLayout::eTransferSrc);
textures[0] = left;
// 加载right,top,bottom,front,back
for (int i = 0; i < 6; i++)
{
// 首先需要将Cubemap的第i个面变换到TransferDst
pool->TransitionImageLayout(
GetLowlevelImage(), GetLowlevelFormat(), vk::ImageLayout::eUndefined, vk::ImageLayout::eTransferDstOptimal, 1, 1,
i // 这里指定了第几个面
);
vk::ImageCopy copy;
copy.srcSubresource.aspectMask = vk::ImageAspectFlagBits::eColor;
copy.srcSubresource.layerCount = 1;
copy.srcOffset = {{0, 0, 0}};
copy.dstSubresource.aspectMask = vk::ImageAspectFlagBits::eColor;
copy.dstSubresource.baseArrayLayer = i;
copy.dstSubresource.layerCount = 1;
copy.dstOffset = {{0, 0, 0}};
copy.extent = {{(uint32_t)width_, (uint32_t)height_, 1}};
// 复制图像内存
pool->CopyImage(textures[i]->GetLowlevelImage(), GetLowlevelImage(), {copy});
// 再从TransferDst变换到ShaderReadOnlyOptimal
pool->TransitionImageLayout(
GetLowlevelImage(), GetLowlevelFormat(), vk::ImageLayout::eTransferDstOptimal, vk::ImageLayout::eShaderReadOnlyOptimal, 1, 1, i
);
}
rhi_texture_view_ = CreateView(); // 创建代表cubemap本身的imageview 这里代码经过简化
for (int i = 0; i < 6; i++)
{
view_create_info.subresourceRange.baseArrayLayer = i;
view_names_[i] = image_info.name + std::to_string(i);
views_[i] = CreateView(i); // 这里创建立方体贴图六个面的view以供ImGui显示
}
// 释放ResourceManager持有的Texture资源, 因为它们现在的Layout是TransferSrc
for (int i = 0; i < 6; i++)
{
ResourceManager::Get()->DestroyResource(textures[i]->GetPath());
}
}

上面就是详细的加载流程,此外为了完成天空盒,我新增了SkyboxPass、SkyboxMaterial,以及它们对应的Shader

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
layout(location = 0) in vec3 inPosition;

layout(binding = 0) uniform UboView {
mat4 projection;
mat4 view;
} ubo_view;

layout(location = 0) out vec3 outWorldPosition;

void main() {
mat4 viewNoTranslation = mat4(mat3(ubo_view.view)); // 1 去除相机空间的位移,使得方块永远在相机空间原点
vec4 pos = ubo_view.projection * viewNoTranslation * vec4(inPosition, 1.0);
gl_Position = pos.xyww; // 2 保证天空盒的深度为1
outWorldPosition = inPosition;
}

①处是为了让方块永远在相机空间原点,如果去掉了这句,那么

image-20240825143552763

Skybox真的就只是一个”Box”而已。。。

②处是为了保证天空盒的深度永远为1,让它永远绘制在最后面,如果没有这句,它都不会绘制天空盒

image-20240825143913686

我们在vert shader中还将传入的position传给了Frag shader,因为由于这个立方体永远在相机空间原点,这个position就可以当做采样点:

1
2
3
4
5
6
7
8
9
10
11
#version 450

layout(location = 0) in vec3 inWorldPosition;

layout(binding = 1) uniform samplerCube sky;

layout(location = 0) out vec4 outColor;

void main() {
outColor = texture(sky, inWorldPosition);
}

对于C++代码,我们需要注意的是由于我们在立方体内部,而且我还是直接绘制的Mesh,因此管线创建的时候需要指定背面剔除里判断背面的方法为逆时针为背面,否则绘制的时候天空盒会被背面剔除:

1
2
3
config.use_counter_clock_wise_front_face = false;
config.use_depth_write = false;
sky_box_material_ = MaterialManager::CreateMaterial<SkyboxMaterial>(sky_vert, sky_frag, skybox_pass_, L"SkyboxMaterial", config);

绘制时就绘制一个方块就好:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// LiteForwardRenderPipeline.cpp
// 绘制skybox
if (sky_box_material_->HasSetSkyTexture())
{
skybox_pass_->Begin(cb, main->background_color);
sky_box_material_->Use(cb);
sky_box_material_->SetProjectionView(main);
sky_box_material_->DrawSkybox(cb);
skybox_pass_->End(cb);
}

// SkyboxMaterial.cpp
void SkyboxMaterial::DrawSkybox(vk::CommandBuffer cb)
{
if (!skybox_mesh_)
{
skybox_mesh_ = Resource::Mesh::Create(L"Models/Cube.fbx");
}
pipeline_->BindMesh(cb, *skybox_mesh_->GetSubMeshes()[0].GetRHIResource());
pipeline_->BindDescriptiorSets(cb, {pipeline_->GetCurrentFrameDescriptorSet()}, vk::PipelineBindPoint::eGraphics, 0);
pipeline_->DrawIndexed(cb, skybox_mesh_->GetIndexCount());
// clang-format on
}

这就是立方体贴图天空盒的绘制方法。

对于球面环境映射,我们使用Texture直接加载一个hdr/png/jpg,然后像上面调用的就行了,需要注意的是它的frag shader的uv坐标的计算方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
#version 450

layout(location = 0) in vec3 inWorldPosition;

layout(binding = 1) uniform sampler2D sky;

layout(location = 0) out vec4 outColor;

void main() {
vec3 direction = normalize(inWorldPosition);
vec2 uv = direction.xy * vec2(0.5, -0.5) + vec2(0.5, 0.5);
outColor = texture(sky, uv);
}

这个uv坐标如何来的?可见环境映射技术漫谈 - 知乎 (zhihu.com)

里面实际上要求的是球体法线的xy分量,但是我们传进来的worldPosition本身就代表了法线,因此我们将它归一化就行。给y乘-0.5是为了解决它上下颠倒的问题。

到这里我们天空盒就基本实现了。

新的Shader编译流程

之前的Shader加载的都是spv文件,意味着每次都需要自己出去手动编译,异常繁琐,因此我引入了新的Shader编译流程,即:

检查vert/spv是否变化->有变化自动触发编译/没变化读取文件。

为了检测是否发生变化,引入了openssl计算文件哈希,引入了nlohmann-json保存哈希值。

如果变化,引入了shaderc进行编译,shaderc是对glsl compiler的一个封装,可以将glsl编译至shaderc

相关代码可以在Shader.cpp里的ShaderCache类里找到。

结语

本篇文章主要介绍了天空盒的实现方法。

下一篇文章有很多选择,因为现在代码遗留的能短时间解决的东西有几个:

  • 事件系统的重构,之前的事件系统太单一了,相当于只有多播,而且取消注册做的也不好
  • 无限网格,就像Unity Editor里的那样
  • 协程,我最近对C++协程有点兴趣,想在小引擎上实践一下

大概就这三个,后面的顺序很可能是无限网格->事件系统->协程

引用