跳转至

阴影映射

前言

在之前的章节中,我们只用到了一个管线、渲染通道和子通道来渲染场景。 然而,实际渲染流程往往需要多个图形管线协作完成,例如实现阴影、镜面反射和后处理等。

在本章中,我们将通过 Phong 光照模型,渲染一个带光照和阴影的简单场景。 我们将使用最简单的 阴影映射 方式来实现阴影效果,先生成光源视角下的深度图,然后在渲染主场景时使用该深度图来判断片元是否被遮挡。

由于渲染目标不同,需要分别用两个渲染通道处理深度图和主场景。

如果目标相同,可以使用一个渲染通道和多个子通道来实现。

基础代码

请下载下方的代码并编译运行:

点击下载代码

本场景使用了 GAMES202 课程作业1的模型,建议阅读本章前先完成阴影映射部分的理论学习。

自行移动摄像机位置,你将看到类似下面的效果:

Marry

本章节基础代码基于“C++模块化”章节的示例二代码,主要做出如下修改:

  • 添加 LightUBO ,用于传递光源信息。
  • 修改模型读取代码,读取地面和Marry人物模型,以及材质数据。
  • 添加实例缓冲区,存储模型自身的材质数据。
  • 使用推送常量控制是否纹理采样。
  • 创建深度图所需图像和采样器,但未使用。
  • 修改着色器,添加简单的 Phong 光照计算逻辑。

深度图

在渲染阴影时,我们需要先生成一个深度图,记录光源视角下的场景深度信息。 基础代码已经创建了深度图所需的图像资源和采样器,但尚未真正使用。

前言部分提到,我们需要使用两个渲染通道来分别处理深度图和主场景的渲染。 因此需要创建一个新的图形管线和渲染通道用于生成深度图,此管线无需绘制最终颜色、只需记录深度信息,因此无需颜色附件和片段着色器。 深度图像将作为附件添加到第一个渲染通道

在第二个渲染通道中,我们将使用该深度图来判断片元对于光源是否可见,需要使用采样器来读取深度图像。 因此图像和采样器在第二个管线将使用描述符集以供着色器访问,而非附件的形式。

渲染通道

现在创建一个新的渲染通道用于生成深度图。

1. 阴影渲染通道模块

现在创建一个 ShadowRenderPass.cppm 模块文件,负责创建阴影渲染通道和帧缓冲区。

module;
#include <memory>
#include <vector>
export module ShadowRenderPass;

import vulkan_hpp;
import Device;
import ShadowDepthImage;

export namespace vht {
    class ShadowRenderPass {
        std::shared_ptr<vht::Device> m_device{ nullptr };
        std::shared_ptr<vht::ShadowDepthImage> m_shadow_depth_image{ nullptr };
        vk::raii::RenderPass m_render_pass{ nullptr };
        std::vector<vk::raii::Framebuffer> m_framebuffers;
        vk::Extent2D m_extent{};  // 使用阴影深度图的宽度和高度
    public:
        explicit ShadowRenderPass(
            std::shared_ptr<vht::Device> device,
            std::shared_ptr<vht::ShadowDepthImage> shadow_depth_image)
        :   m_device(std::move(device)),
            m_shadow_depth_image(std::move(shadow_depth_image)) {
            m_extent.width = m_shadow_depth_image->width();
            m_extent.height = m_shadow_depth_image->height();
            create_render_pass();
            create_framebuffers();
        }

        [[nodiscard]]
        const vk::raii::RenderPass& render_pass() const { return m_render_pass; }
        [[nodiscard]]
        const std::vector<vk::raii::Framebuffer>& framebuffers() const { return m_framebuffers; }
        [[nodiscard]]
        const vk::Extent2D& extent() const { return m_extent; } // 命令缓冲录制时需要使用
    private:
        // 创建渲染通道
        void create_render_pass() {

        }
        // 创建帧缓冲
        void create_framebuffers() {

        }
    };
}

有一点值得注意,此渲染通道不需要用到“交换链”,更准确的说是不需要获取交换链图像的大小。 这是因为深度图不用于屏幕显示,第二次渲染使用采样器读取时使用 [0,1] 表示图像范围,因此不需要考虑交换链的图像大小。 此外,它也不需要在窗口变化时重建。

深度图大小可根据需求设定,它在 ShadowDepthImage.cppm 模块的类成员中指定。 越大的深度图,最后表现出的阴影效果越精细,但会消耗更多的显存和计算资源。

2. 创建渲染通道

渲染通道与帧缓冲的创建过程与之前类似,但此通道只需要一个深度附件,无需颜色附件。

void create_render_pass() {
    vk::AttachmentDescription depth_attachment;
    depth_attachment.format = m_shadow_depth_image->format();
    depth_attachment.samples = vk::SampleCountFlagBits::e1;
    depth_attachment.loadOp = vk::AttachmentLoadOp::eClear;
    depth_attachment.storeOp = vk::AttachmentStoreOp::eStore; // 阴影深度图像通常需要存储
    depth_attachment.stencilLoadOp = vk::AttachmentLoadOp::eDontCare;
    depth_attachment.stencilStoreOp = vk::AttachmentStoreOp::eDontCare;
    depth_attachment.initialLayout = vk::ImageLayout::eUndefined;
    depth_attachment.finalLayout = vk::ImageLayout::eShaderReadOnlyOptimal; // 后续需要让着色器读取

    vk::AttachmentReference depth_attachment_ref;
    depth_attachment_ref.attachment = 0;
    depth_attachment_ref.layout = vk::ImageLayout::eDepthStencilAttachmentOptimal;

    vk::SubpassDescription subpass;
    subpass.pipelineBindPoint = vk::PipelineBindPoint::eGraphics;
    subpass.setPDepthStencilAttachment( &depth_attachment_ref );

    vk::RenderPassCreateInfo create_info;
    create_info.setAttachments( depth_attachment );
    create_info.setSubpasses( subpass );

    m_render_pass = m_device->device().createRenderPass( create_info );
}

注意附件信息,我们要保留深度图像的存储操作,以便后续渲染时可以读取。 而且后续要让第二个管线的着色器读取,因此需要将深度图像的最终布局设置为 eShaderReadOnlyOptimal

3. 创建帧缓冲

帧缓冲的创建与之前类似,但需要使用阴影深度图像的视图以及尺寸。

void create_framebuffers() {
    vk::FramebufferCreateInfo create_info;
    create_info.renderPass = m_render_pass;
    create_info.width = m_extent.width;
    create_info.height = m_extent.height;
    create_info.layers = 1;
    for (const auto& image_view : m_shadow_depth_image->image_views()) {
        create_info.setAttachments( *image_view ); // 记得加 * 操作符
        m_framebuffers.emplace_back(m_device->device().createFramebuffer( create_info ));
    }
}

4. 依赖注入

我们后续将在 Drawer 模块中使用此渲染通道,现在为其添加成员:

...
import ShadowRenderPass;
...
class Drawer {
    ...
    std::shared_ptr<vht::ShadowRenderPass> m_shadow_render_pass{ nullptr };
    ...
    std::shared_ptr<vht::ShadowRenderPass> shadow_render_pass, // 构造函数参数
    ...
    m_shadow_render_pass(std::move(shadow_render_pass)), // 构造函数初始化
    ...
};

回到 App.cppm 模块,初始化对象,并为 Drawer 注入依赖:

我们使用共享指针管理资源,可以不那么在意声明顺序,但你需要注意创建顺序。

...
import ShadowRenderPass;
...
class App {
    ...
    std::shared_ptr<vht::ShadowRenderPass> m_shadow_render_pass{ nullptr };
    ...
    init_shadow_render_pass();
    std::cout << "shadow render pass created" << std::endl;
    ...
    void init_shadow_render_pass() { m_shadow_render_pass = std::make_shared<vht::ShadowRenderPass>( m_device, m_shadow_depth_image ); }
    ...
    void init_drawer() {
        m_drawer = std::make_shared<vht::Drawer>(
            ...
            m_shadow_render_pass,
            ...
        );
    }
};

5. 测试

进阶内容的任务多且复杂,建议在每个小阶段完成后都进行测试,尽早发现问题。

着色器

现在需要编写深度图渲染通道的着色器,此管线只需要顶点着色器(因为我们仅记录深度,不需要绘制色彩)。 我们需要在定义着色器中使用“光源”的视角变换矩阵,将顶点位置转换到光源视角下,以便管线后续自动绘制光源视角的场景深度图。

1. 顶点着色器

光源的 MVP 矩阵已经在 LightUBO 中定义,你可以自行调整。

着色器代码直接使用相同的数据格式即可,后续将使用 UBO 传递光源信息。 现在可以在 shaders 文件夹下创建一个新的着色器文件 shadow.vert,内容如下:

#version 450

layout(location = 0) in vec3 inPosition;
layout(location = 1) in vec3 inNormal;
layout(location = 2) in vec2 inTexCoord;

layout(std140, binding = 0) uniform UBO {
    mat4 model;
    mat4 view;
    mat4 proj;
    vec3 lightPos;
    vec3 lightColor;
    vec3 viewPos;
} ubo;

void main() {
    gl_Position = ubo.proj * ubo.view * ubo.model * vec4(inPosition, 1.0);
}

顶点输入可以参考输入装配模块的内容,但深度图不需要实例数据(模型的材质信息),因此我们后续只传顶点数据。

2. CMake脚本

shaders 目录的 CMakeLists.txt 中添加新的着色器编译规则:

# ...
set(SHADOW_SHADER ${SHADER_DIR}/shadow.vert)
# ...
set(SPIRV_SHADOW ${SHADER_DIR}/shadow.spv)
# ...
add_custom_command(
        OUTPUT ${SPIRV_SHADOW}
        COMMAND ${Vulkan_GLSLC_EXECUTABLE} ${SHADOW_SHADER} -o ${SPIRV_SHADOW}
        COMMENT "Compiling shadow.vert vertex shadow.spv"
        DEPENDS ${SHADOW_SHADER}
)

add_custom_target(CompileShaders ALL
        DEPENDS ${SPIRV_VERT} ${SPIRV_FRAG} ${SPIRV_SHADOW}
)

然后尝试编译项目与着色器,确保没有错误。

管线与描述符布局

1. 管线模块

现在创建一个新的管线模块 ShadowPipeline.cppm,用于创建阴影渲染通道的图形管线。

module;
#include <memory>
#include <vector>
export module ShadowPipeline;

import vulkan_hpp;
import DataLoader;
import Utility;
import Device;
import ShadowRenderPass;

export namespace vht {
    class ShadowPipeline {
        std::shared_ptr<vht::Device> m_device;
        std::shared_ptr<vht::ShadowRenderPass> m_shadow_render_pass;
        vk::raii::DescriptorSetLayout m_descriptor_set_layout{ nullptr };
        vk::raii::PipelineLayout m_pipeline_layout{ nullptr };
        vk::raii::Pipeline m_pipeline{ nullptr };
    public:
        explicit ShadowPipeline(std::shared_ptr<vht::Device> device, std::shared_ptr<vht::ShadowRenderPass> shadow_render_pass)
        :   m_device(std::move(device)),
            m_shadow_render_pass(std::move(shadow_render_pass)) {
            create_descriptor_set_layout();
            create_graphics_pipeline();
        }

        [[nodiscard]]
        const vk::raii::DescriptorSetLayout& descriptor_set_layout() const { return m_descriptor_set_layout; }
        [[nodiscard]]
        const vk::raii::PipelineLayout& pipeline_layout() const { return m_pipeline_layout; }
        [[nodiscard]]
        const vk::raii::Pipeline& pipeline() const { return m_pipeline; }

    private:
        void create_descriptor_set_layout() {
            ......
        }
        // 创建图形管线
        void create_graphics_pipeline() {
            ......
            ......
        }

    };
}

2. 描述符集布局

首先创建描述符集布局,包含一个绑定点用于传递光源信息。

void create_descriptor_set_layout() {
    vk::DescriptorSetLayoutBinding light_ubo_layout_binging;
    light_ubo_layout_binging.binding = 0;
    light_ubo_layout_binging.descriptorType = vk::DescriptorType::eUniformBuffer;
    light_ubo_layout_binging.descriptorCount = 1;
    light_ubo_layout_binging.stageFlags = vk::ShaderStageFlagBits::eVertex;

    vk::DescriptorSetLayoutCreateInfo layoutInfo;
    layoutInfo.setBindings( light_ubo_layout_binging );

    m_descriptor_set_layout = m_device->device().createDescriptorSetLayout( layoutInfo );
}

3. 创建图形管线

管线创建方式与之前类似。注意我们只需要顶点着色器,且不需要颜色附件和颜色混合阶段。

// 创建图形管线
void create_graphics_pipeline() {
    const auto vertex_shader_code = vht::read_shader("shaders/shadow.spv");
    const auto vertex_shader_module = vht::create_shader_module(m_device->device(), vertex_shader_code);
    // 只需要顶点着色器
    vk::PipelineShaderStageCreateInfo vertex_shader_create_info;
    vertex_shader_create_info.stage = vk::ShaderStageFlagBits::eVertex;
    vertex_shader_create_info.module = vertex_shader_module;
    vertex_shader_create_info.pName = "main";
    const std::vector<vk::PipelineShaderStageCreateInfo> shader_stages = { vertex_shader_create_info };

    // 顶点输入状态(只需要顶点位置,不需要实例材质数据)
    auto binding_description = vht::Vertex::get_binding_description();
    auto attribute_description = vht::Vertex::get_attribute_description();
     vk::PipelineVertexInputStateCreateInfo vertex_input;
    vertex_input.setVertexBindingDescriptions(binding_description);
    vertex_input.setVertexAttributeDescriptions(attribute_description);
    // 输入装配状态
    vk::PipelineInputAssemblyStateCreateInfo input_assembly;
    input_assembly.topology = vk::PrimitiveTopology::eTriangleList;
    // 视口与裁剪
    // 直接使用静态状态,因为我们的深度图像大小不会变化
    const vk::Viewport viewport(
        0.0f, 0.0f,          // x, y
        static_cast<float>(m_shadow_render_pass->extent().width),    // width
        static_cast<float>(m_shadow_render_pass->extent().height),   // height
        0.0f, 1.0f      // minDepth maxDepth
    );
    const vk::Rect2D scissor(
        vk::Offset2D{0, 0},              // offset
        m_shadow_render_pass->extent()         // extent
    );
    vk::PipelineViewportStateCreateInfo viewport_state;
    viewport_state.setViewports(viewport);
    viewport_state.setScissors(scissor);
    // 深度与模板测试
    vk::PipelineDepthStencilStateCreateInfo depth_stencil;
    depth_stencil.depthTestEnable = true;
    depth_stencil.depthWriteEnable = true;
    depth_stencil.depthCompareOp = vk::CompareOp::eLess;
    // 光栅化器
    vk::PipelineRasterizationStateCreateInfo rasterizer;
    rasterizer.depthClampEnable = false;
    rasterizer.rasterizerDiscardEnable = false;
    rasterizer.polygonMode = vk::PolygonMode::eFill;
    rasterizer.lineWidth = 1.0f;
    // 注意此处不剔除(和主渲染一样剔除背面的话会出问题,但可以只剔除,让迷枵思考一会。。。)
    rasterizer.cullMode = vk::CullModeFlagBits::eNone;
    rasterizer.frontFace = vk::FrontFace::eCounterClockwise;
    rasterizer.depthBiasEnable = false;
    // 多重采样
    vk::PipelineMultisampleStateCreateInfo multisampling;
    multisampling.rasterizationSamples =  vk::SampleCountFlagBits::e1;
    multisampling.sampleShadingEnable = false;  // default
    // 管线布局,引用描述符集布局
    vk::PipelineLayoutCreateInfo layout_create_info;
    layout_create_info.setSetLayouts( *m_descriptor_set_layout );
    m_pipeline_layout = m_device->device().createPipelineLayout( layout_create_info );
    // 创建图形管线
    vk::GraphicsPipelineCreateInfo create_info;
    create_info.layout = m_pipeline_layout;

    create_info.setStages( shader_stages );
    create_info.pVertexInputState =  &vertex_input;
    create_info.pInputAssemblyState = &input_assembly;
    create_info.pViewportState = &viewport_state;
    create_info.pDepthStencilState = &depth_stencil;
    create_info.pRasterizationState = &rasterizer;
    create_info.pMultisampleState = &multisampling;
    // 不需要颜色混合阶段
    // 不需要管线动态状态

    create_info.renderPass = m_shadow_render_pass->render_pass();
    create_info.subpass = 0;

    m_pipeline = m_device->device().createGraphicsPipeline( nullptr, create_info );
}

4. 依赖注入

我们至少有两个地方需要使用阴影管线,一个是 Drawer 模块,另一个是 Descriptor 模块。 现在需要为它们注入依赖,首先修改 Drawer.cppm 模块:

...
import ShadowPipeline;
...
class Drawer {
    ...
    std::shared_ptr<vht::ShadowPipeline> m_shadow_pipeline{ nullptr };
    ...
    std::shared_ptr<vht::ShadowPipeline> shadow_pipeline,   // 构造函数参数
    ...
    m_shadow_pipeline(std::move(shadow_pipeline)),      // 构造函数初始化
    ...
};

然后是 Descriptor.cppm 模块:

...
import ShadowPipeline;
...
class Descriptor {
    ...
    std::shared_ptr<vht::ShadowPipeline> m_shadow_pipeline{ nullptr };
    ...
    std::shared_ptr<vht::ShadowPipeline> shadow_pipeline,
    ...
    m_shadow_pipeline(std::move(shadow_pipeline)),
    ...
};

最后在 App.cppm 模块中初始化阴影管线,并修改上面两个模块对象的初始化语句:

...
import ShadowPipeline;
...
class App {
    ...
    std::shared_ptr<vht::ShadowPipeline> m_shadow_pipeline{ nullptr };
    ...
    init_shadow_pipeline();
    std::cout << "shadow pipeline created" << std::endl;
    ...
    void init_shadow_pipeline() { m_shadow_pipeline = std::make_shared<vht::ShadowPipeline>(m_device, m_shadow_render_pass); }
    ...
    void init_descriptor() {
        m_descriptor = std::make_shared<vht::Descriptor>(
            ...
            m_shadow_pipeline,
            ...
        );
    }
    void init_drawer() {
        m_drawer = std::make_shared<vht::Drawer>(
            ...
            m_shadow_pipeline,
            ...
        );
    }
};

现在请编译运行项目,确保没有错误。

描述符集

现在我们需要创建描述符集,以便在渲染阴影时传递光源信息。

1. 存储描述符

现在回到 Descriptor.cppm 模块,创建变量来存储阴影管线的描述符集。

class Descriptor {
    ...
    // 添加成员变量
    std::vector<vk::raii::DescriptorSet> m_shadow_sets;
    ...
    // 对外接口
    [[nodiscard]]
    const std::vector<vk::raii::DescriptorSet>& shadow_sets() const { return m_shadow_sets; }
    ...
};

1. 修改描述符池

修改描述符池的创建代码,需要添加描述符的数量:

void create_descriptor_pool() {
    std::array<vk::DescriptorPoolSize, 2> pool_sizes;
    pool_sizes[0].type = vk::DescriptorType::eUniformBuffer;
    // 一份 UBO + 2 份 Light UBO
    pool_sizes[0].descriptorCount = static_cast<uint32_t>(MAX_FRAMES_IN_FLIGHT * 3); 
    ......
    // 两个管线都需要 MAX_FRAMES_IN_FLIGHT
    poolInfo.maxSets = static_cast<uint32_t>(MAX_FRAMES_IN_FLIGHT * 2); 

    m_pool = m_device->device().createDescriptorPool(poolInfo);
}

注意描述符集的最大数量和描述符的数量都需要修改。

2. 创建描述符集布局

void create_descriptor_sets() {
    // 为阴影管线创建描述符集布局
    std::vector<vk::DescriptorSetLayout> shadow_layouts(MAX_FRAMES_IN_FLIGHT, *m_shadow_pipeline->descriptor_set_layout());
    vk::DescriptorSetAllocateInfo shadow_alloc_info;
    shadow_alloc_info.descriptorPool = m_pool;
    shadow_alloc_info.setSetLayouts( shadow_layouts );

    m_shadow_sets = m_device->device().allocateDescriptorSets(shadow_alloc_info);

    for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; ++i) {
        vk::DescriptorBufferInfo shadow_light_buffer_info;
        shadow_light_buffer_info.buffer = m_light_uniform_buffer->buffers()[i];
        shadow_light_buffer_info.offset = 0;
        shadow_light_buffer_info.range = sizeof(LightUBO);

        vk::WriteDescriptorSet write;
        write.dstSet = m_shadow_sets[i];
        write.dstBinding = 0;
        write.dstArrayElement = 0;
        write.descriptorType = vk::DescriptorType::eUniformBuffer;
        write.setBufferInfo(shadow_light_buffer_info);

        m_device->device().updateDescriptorSets(write, nullptr);
    }

    // 第二个管线的描述符集
    ......
    ......
}

现在再次运行程序,确保没有错误。

绘制命令记录

现在回到 Drawer.cppm 模块,修改命令缓冲的记录,添加“阴影”渲染通道的绘制命令。

// 记录命令缓冲区
void record_command_buffer(const vk::raii::CommandBuffer& command_buffer, const uint32_t image_index) const {
    // 开始记录命令缓冲区
    command_buffer.begin( vk::CommandBufferBeginInfo{} );

    // 记录阴影渲染通道的命令
    vk::RenderPassBeginInfo shadow_pass_begin_info;
    shadow_pass_begin_info.renderPass = m_shadow_render_pass->render_pass();
    // 注意这里的帧缓冲区是阴影渲染通道的帧缓冲,使用 current_frame 索引而不是 image_index
    shadow_pass_begin_info.framebuffer = m_shadow_render_pass->framebuffers()[m_current_frame];
    shadow_pass_begin_info.renderArea.offset = vk::Offset2D{0, 0};
    shadow_pass_begin_info.renderArea.extent = m_shadow_render_pass->extent(); // 注意这里使用阴影渲染通道的尺寸
    constexpr vk::ClearValue shadow_clear_color = vk::ClearDepthStencilValue{ 1.0f ,0 };
    shadow_pass_begin_info.setClearValues( shadow_clear_color );

    command_buffer.beginRenderPass( shadow_pass_begin_info, vk::SubpassContents::eInline );
    command_buffer.bindPipeline( vk::PipelineBindPoint::eGraphics, m_shadow_pipeline->pipeline() );
    // 我们使用了静态视口与裁剪,因此此处无需设置
    const std::vector<vk::Buffer> shadow_vertex_buffers = { *m_input_assembly->vertex_buffer() };
    constexpr std::array<vk::DeviceSize,1> shadow_offsets = { 0 };
    command_buffer.bindVertexBuffers( 0, shadow_vertex_buffers, shadow_offsets );
    command_buffer.bindIndexBuffer( m_input_assembly->index_buffer(), 0, vk::IndexType::eUint32 );
    command_buffer.bindDescriptorSets(
        vk::PipelineBindPoint::eGraphics,
        m_shadow_pipeline->pipeline_layout(),
        0,
        *m_descriptor->shadow_sets()[m_current_frame],
        nullptr
    );
    command_buffer.drawIndexed(static_cast<uint32_t>(m_data_loader->indices().size()), 1, 0, 0, 0);
    command_buffer.endRenderPass();


    // 记录主渲染通道的命令,没有任何修改
    ......
    ......

    command_buffer.end();
}

重点注意,这里的渲染渲染范围使用的是阴影深度图像的大小,而非交换链图像的大小。

现在编译运行程序,确保没有错误。

图像采样描述符

现在我们的视频效果还没有变化,因为我们还没有修改主渲染的代码。 首先,我们需要让主渲染的着色器能够读取阴影深度图像,需要创建图像与采样器描述符。

1. 描述符布局

首先修改 GraphicsPipeline.cppm 模块,添加一个新的描述符布局,用于传递阴影深度图像和采样器。

// 创建描述符集布局
void create_descriptor_set_layout() {
    ...
    // 这里依然使用组合图像采样器描述符
    vk::DescriptorSetLayoutBinding depth_map_layout_binding;
    depth_map_layout_binding.binding = 3;
    depth_map_layout_binding.descriptorType = vk::DescriptorType::eCombinedImageSampler;
    depth_map_layout_binding.descriptorCount = 1;
    depth_map_layout_binding.stageFlags = vk::ShaderStageFlagBits::eFragment;

    auto bindings = {
        ubo_layout_binging,
        sampler_layout_binding,
        light_ubo_layout_binging,
        depth_map_layout_binding // 添加深度图像采样器描述符
    };
    ...
}

2. 分配描述符集合

Descriptor.cppm 模块中,首先修改描述符池的配置,添加深度图像采样器的描述符数量:

void create_descriptor_pool() {
    ...
    pool_sizes[1].descriptorCount = static_cast<uint32_t>(MAX_FRAMES_IN_FLIGHT * 2); // 纹理采样器 + 阴影贴图
    ...
}

然后在 create_descriptor_sets 方法中,添加深度图像采样器的描述符集:

import ShadowDepthImage; // 导入模块

...
class Descriptor {
    ...
    std::shared_ptr<vht::ShadowDepthImage> m_shadow_depth_image{ nullptr }; // 添加成员变量
    ...
    std::shared_ptr<vht::ShadowDepthImage> shadow_depth_image, // 构造函数参数
    ...
    m_shadow_depth_image(std::move(shadow_depth_image)), // 构造函数初始化
    ...
    void create_descriptor_sets() {
        // 第一个管线的描述符集,略
        ...

        // 主渲染管线的描述符集
        ......
        // 添加深度图像采样器描述符信息
        vk::DescriptorImageInfo depth_map_info;
        depth_map_info.imageLayout = vk::ImageLayout::eShaderReadOnlyOptimal;
        depth_map_info.imageView = m_shadow_depth_image->image_views()[i];
        depth_map_info.sampler = m_shadow_depth_image->sampler();
        // 增加数组元素
        std::array<vk::WriteDescriptorSet, 4> writes;
        ...
        writes[3].dstSet = m_sets[i];
        writes[3].dstBinding = 3;
        writes[3].dstArrayElement = 0;
        writes[3].descriptorType = vk::DescriptorType::eCombinedImageSampler;
        writes[3].setImageInfo(depth_map_info);
        ...
    }
};

别忘了修改 App.cppmDescriptor 的构造函数,添加 ShadowDepthImage 的依赖注入:

void init_descriptor() {
    m_descriptor = std::make_shared<vht::Descriptor>(
        ...
        m_shadow_depth_image,
        ...
    );
}

3. 片段着色器

现在我们需要修改主渲染管线的片段着色器 shader.frag ,先尝试添加描述符,测试是否可以编译通过:

// ......

// 添加阴影深度图像采样器
layout(binding = 3) uniform sampler2D depthSampler;

// ......

然后尝试编译和运行项目,确保没有错误。

片段着色器逻辑

现在来到最后一步,我们需要在片段着色器中实现阴影映射的逻辑。

void main() {
    // 计算光源空间坐标
    vec4 lightSpacePos = ubo.proj * ubo.view * ubo.model * vec4(fragPos, 1.0);
    vec3 projCoords = lightSpacePos.xyz / lightSpacePos.w;
    projCoords.xy = projCoords.xy * 0.5 + 0.5; // 将 x,y 转换到[0,1]区间

    float shadow = 0.0; // 光照范围之外直接设为 0
    if(projCoords.x > 0.0 && projCoords.x < 1.0 && projCoords.y > 0.0 && projCoords.y < 1.0) {
        float closestDepth = texture(depthSampler, projCoords.xy).r;
        float currentDepth = projCoords.z;
        // 阴影判断
        float bias = 0.008;
        shadow = currentDepth - bias > closestDepth ? 0.5 : 1.0; // 0.5表示阴影,1.0表示光照
    }

    // ... Phong 光照计算逻辑

    // 最终色彩,乘以光照和阴影
    vec3 result = (ambient + shadow * (diffuse + specular)) * objectColor;
    result = min(result, vec3(1.0));
    outColor = vec4(result, 1.0);
}

顶点着色器向片段着色器传递了顶点位置 fragPos,我们通过光源的 MVP 矩阵将其转换到光源视角下的坐标系。 然后将顶点与光源的距离(Z分量)与深度图像中存储的距离进行比较,判断该片元是否被其他物体遮挡。

MVP 变换后的坐标可能在 [0,1] 之外,我们仅考虑范围内的顶点。

最后

现在编译运行程序,你应该可以看到阴影效果了:

ShadowMap

如果你仔细观察,可能发现小腿后面没有阴影。这在 GAMES202 课程中有提到,是我们着色器中的 bias 设置过大导致的,你可以尝试降低 bias 的值来改善这个问题。

你可能好奇深度图是什么样子,这里提供一种简单的呈现方式。 注意到地面没有绘制纹理,而它的纹理坐标正是 [0.0,0.0] - [1.0,1.0],我们可以修改地面的着色逻辑,让它呈现深度图的内容:

// 原先,根据推送常量觉得是否采样纹理,地面使用固定色彩
// vec3 objectColor = pc.enableTexture == 1 ? texture(texSampler, fragTexCoord).rgb : vec3(0.5, 0.5, 0.5);
// 修改后,地面采样深度图形
float depth = texture(depthSampler, fragTexCoord.xy).r;
depth = (depth - 0.90) * 10.0;      // 让深度图效果更明显
depth = pow(depth, 6.0);          // 让深度图效果更明显
vec3 objectColor = pc.enableTexture == 1 ? texture(texSampler, fragTexCoord).rgb : vec3(depth, depth, depth);

上面通过截断放大和幂运算来增强深度图的可视化效果,如果你不这么做,深度差异过小将导致地面全白看不出差异。

depth_image

作为进阶章节,作者希望你能实现更多扩展功能,例如:

  • 实现光源位置的动态变化。
  • 改进阴影效果,例如使用 PCF/PCSS 来平滑阴影边缘。

初始代码集

shadow.vert(新增)

shader.frag(修改)

frag.diff(差异文件)

shaders/CMakeLists.txt(修改)

shaders/CMakeLists.diff(差异文件)

ShadowPipeline.cppm(新增)

ShadowRenderPass.cppm(新增)

App.cppm(修改)

App.diff(差异文件)

Descriptor.cppm(修改)

Descriptor.diff(差异文件)

Drawer.cppm(修改)

Drawer.diff(差异文件)

GraphicsPipeline.cppm(修改)

GraphicsPipeline.diff(差异文件)


评论