Computer Graphics/Vulkan

Vulkan Game Engine - 22. shadowmap에 storage buffer, push constants 적용

surkim 2025. 3. 17. 14:02

아오.. 처음부터 고려하면서 했어야했다.

이제와서 고치니깐 고칠 것이 많아서 약간 애먹었다.

사실 아직 다 못고쳤는데 이 정도까지는 픽스일 거같아 먼저 블로그에 올린다.

 

현재 문제는 RenderingComponent마다 descriptorset이 생겨 많은 오브젝트에 비례해서 descriptorset이 생기는 문제였다.

 

일단은 shadow를 그리기위한 descriptorset을 통합시켰다.

어떻게 해결했고 어떤 고민을 했는지 써놔야겠다.

 

일단 결과물

여기서 

왼쪽 directional light일 때 반쯤 그림자 적용안되는거는 그림자를 조금 낮은곳에서 찍어서 그렇다. 나중에 CSM에 대해 공부하자

이렇게 개선됐다.

역시 1000개가지고 프레임 드랍되면 말이 안되지 

아직 다 완성 못했지만 바로 정상화된게 보여 뿌듯하다..ㅎ

 

우선 descirptorset을 줄이기 위한 방법은 많았고 거기서 선택을 했다.

3가지를 고민했었는데 뭐가 더 좋은 방법인지는 모르겠다. 다 적용 후에 실험도 해보고 싶은데... 시간에 너무 쫒기는 느낌이다.

1. uniform buffer를 다이나믹하게 조정

2. storage buffer를 도입

3. push constants를 도입

 

결국 선택은 uniform buffer는 고정시키고 storage buffer와 push constants를 도입했다.

각각 장단이 있는 거 같은데 공부한거 잠깐 메모하자면

 

우선 vulkan에서 buffer는 만들면 크기변경은 불가능하다.

크기를 늘리려면 무조건 부수고 다시 만들어야한다.

 

여기서 uniform buffer와 storage buffer가  descirptorset에 바인딩 되었을 때 조금 차이가 있는데

캐시 히트는 uniform buffer가 높다. gpu가 작업할 때 찾아들어가기가 빠르다고 한다. 얼마나 빨라질지는 잘 모르겠다.

storage buffer는 크기 제한이 거의 없고 uniform buffer와 다르게 이 buffer를 파괴한다해도 descriptorset까지 다시 만들 필요가 없다. 업데이트만 해주면된다. 대신 약간 느리다는데 지금 써봤을 때는 차이를 잘 모르겠다. 완전완전 큰 프로젝트에나 체감될 듯 하다.

push constants는 gpu에 정보를 업로드하는 속도가 가장 빠르다. 대신 만약 이걸 model matrix 업로드하는데 쓰면 draw호출을 model마다 해줘야한다는 단점이 있다.

 

여기서 고민을 조금 해봤는데

우선 descriptorset을 줄이는게 주 목적이니 뭘 써도 상관 없었다.

그래도 좋은게 좋은거라고 최대한 최적화를 신경써봤다.

 

우선 shadowmap 만드는 예전 코드는

#version 450

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

layout(binding = 0) uniform ShadowUniformBufferObject {
    mat4 proj;
    mat4 view;
    mat4 model;
};

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

 

간단한데 여기서 문제가 model matrix가 오브젝트마다 달라 오브젝트마다 descriptorset을 생성해준 것이었다.

proj, view는 사실상 광원마다 달라지는 거니깐 고정이라면 shader를 이렇게 고칠 수 있다.

#version 450

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

layout(set = 0, binding = 0) uniform ShadowUBO {
    mat4 proj;
    mat4 view;
} ubo;

layout(set = 0, binding = 1) buffer ObjectBuffer {
    mat4 model[];
} ssbo;

void main() {
    mat4 modelMatrix = ssbo.model[gl_InstanceIndex];
    gl_Position = ubo.proj * ubo.view * modelMatrix * vec4(inPosition, 1.0);
}

공유하는 부분은 uniform buffer로

달라지는 부분은 큼지막하게 storage buffer로 해주면된다.

 

그럼 descriptorset에 딱 mat4두개만 들어가는 컴팩트한 uniform buffer와

오브젝트 개수에 따라 크기가 달라지는 넉넉한 storage buffer를 바인딩하고

storage buffer에 모든 오브젝트 model matrix를 넣어두면 끝!

그럼 하나 광원일 때 하나의 descriptorset으로 모든 오브젝트를 처리 가능하다.

 

대신 여기서 고려해줘야할게 mesh별로 draw호출은 달리해줘야한다는 문제가 있다.

그래서 배운게 인스터싱!

storage buffer의 인덱스를 조정해서 셰이더에 보내줄 수 있다.

void Mesh::drawShadowSSBO(VkCommandBuffer commandBuffer, uint32_t instanceCount, uint32_t firstInstance)
{
	m_vertexBuffer->bind(commandBuffer);
	m_indexBuffer->bind(commandBuffer);
	vkCmdDrawIndexed(commandBuffer, m_indexBuffer->getIndexCount(), instanceCount, 0, 0, firstInstance);
}

 

storage buffer에 적재하는 과정이 중요하다.

scene에서 transform, meshrenderer 컴포넌트가 포함된 엔티티를 가져오면 얘네들은 mesh별로 정렬이 되있지않아서

auto &view = scene->getAllEntitiesWith<TransformComponent, TagComponent, MeshRendererComponent>();

 

mesh마다 정렬해서 storage buffer에 넣어줄 필요가 있었는데

벡터 맵 벡터를 활용했다...ㅋㅋㅋ

std::vector<std::map<std::string, std::vector<alglm::mat4>>> m_shadowMapModels;

 

애초에 mesh를 가지고있는 model을 unordered_map<string, model*>으로 관리 중이었는데 model마다 본인이 만들어진 경로로 각자 이름이 있어서 그걸 활용했다.

 

m_shadowMapModels[currentFrame].clear();

auto &view = scene->getAllEntitiesWith<TransformComponent, TagComponent, MeshRendererComponent>();

for (auto &entity : view)
{
    if (!view.get<TagComponent>(entity).m_isActive || view.get<MeshRendererComponent>(entity).type == 0)
    {
        continue;
    }
    MeshRendererComponent &meshRendererComponent = view.get<MeshRendererComponent>(entity);
    if (meshRendererComponent.cullState != ECullState::RENDER)
    {
        continue;
    }
    TransformComponent &transformComponent = view.get<TransformComponent>(entity);
    alglm::mat4 &model = transformComponent.m_WorldTransform;
    std::string &modelName = meshRendererComponent.m_RenderingComponent->getModelName();
    m_shadowMapModels[currentFrame][modelName].push_back(model);
}

std::vector<ShadowMapSSBO> ssbo;

for (auto &modelKeyValue : m_shadowMapModels[currentFrame])
{
    std::vector<alglm::mat4> &modelMatrices = modelKeyValue.second;

    for (auto &modelMatrix : modelMatrices)
    {
        ssbo.push_back(ShadowMapSSBO{modelMatrix});
    }
}

if (m_shadowMapSSBO[currentFrame]->getCurrentSize() < ssbo.size() * sizeof(ShadowMapSSBO))
{
    std::cout << "resize shadow map ssbo" << std::endl;
    vkDeviceWaitIdle(device);
    m_shadowMapSSBO[0]->resizeStorageBuffer(ssbo.size() * sizeof(ShadowMapSSBO));
    m_shadowMapSSBO[1]->resizeStorageBuffer(ssbo.size() * sizeof(ShadowMapSSBO));
    for (size_t i = 0; i < 4; i++)
    {
        m_shadowMapShaderResourceManagerSSBO[i]->changeShadowMapSSBO(m_shadowMapSSBO);
        m_shadowCubeMapShaderResourceManagerSSBO[i]->changeShadowCubeMapSSBO(m_shadowMapSSBO);
    }
}
m_shadowMapSSBO[currentFrame]->updateStorageBuffer(ssbo.data(), ssbo.size() * sizeof(ShadowMapSSBO));

자잘한 건 중요하지 않고 첫번째 for문에서 m_shadowMapModels에 model matrix 추가와

다음 for문에서 ssbo 벡터를 만들어서 storage buffer를 만들어주고

마지막에 update하는 로직만 보면 된다.

 

여기서 마지막에 storage buffer를 resize 해주는 부분보면

원래 uniform buffer는 shaderResourceManager가 descriptorset과 같이 관리하고 있었는데

storage buffer (m_shadowMapSSBO) 는 shaderResourceManager 안에서 관리하는것이 아닌 밖인 renderer에서 관리하는 것을 볼 수 있는데

그림자 적용은 4 + 4개 (point는 따로 해야해서)이고

각각 shaderResourceManager가 storage buffer를 가지고 있을 필요가 없어서

storage buffer를 공유해주기위해 renderer에서 만들어서 shaderResourceManager만들때 넣어준다.

8개의 shaderResourceManager가 하나의 storage buffer를 가지고 descriptorset을 만드는 것이다.

 

이러고 나중에 드로우 호출부분에서

ShadowMapUBO ubo{};
ubo.view = lightView;
ubo.proj = lightProj;

shadowMapUniformBuffersSSBO[shadowMapIndex][currentFrame]->updateUniformBuffer(&ubo, sizeof(ubo));
vkCmdBindDescriptorSets(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, shadowMapPipelineLayoutSSBO, 0, 1,
                        &shadowMapDescriptorSetsSSBO[shadowMapIndex][currentFrame], 0, nullptr);

uint32_t modelIndex = 0;
for (auto &modelKeyValue : m_shadowMapModels[currentFrame])
{
    auto &model = m_modelsMap[modelKeyValue.first];
    model->drawShadowSSBO(commandBuffer, modelKeyValue.second.size(), modelIndex);
    modelIndex += modelKeyValue.second.size();
}

이런식으로 인덱싱 나눠서 mesh까지 보내 draw호출하면 된다!

 

shadowCubeMap을 만들때는 push constants를 활용해서

#version 450
#extension GL_ARB_shader_viewport_layer_array : enable

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

layout(set = 0, binding = 0) uniform ShadowUniformBufferObject {
    mat4 proj;
    mat4 view[6];
} ubo;

layout(set = 0, binding = 1) buffer SSBO {
    mat4 model[];
} ssbo;

layout(push_constant) uniform PushConstants {
    uint layerIndex;
} pushData;

void main() {
    gl_Layer = int(pushData.layerIndex);
    gl_Position = ubo.proj * ubo.view[gl_Layer] * ssbo.model[gl_InstanceIndex] * vec4(inPosition, 1.0);
}

 

draw 호출할 때

ShadowCubeMapUBO ubo{};
ubo.view[0] = alglm::lookAt(lightPos, lightPos + alglm::vec3(1.0, 0.0, 0.0), alglm::vec3(0.0, -1.0, 0.0));
ubo.view[1] = alglm::lookAt(lightPos, lightPos + alglm::vec3(-1.0, 0.0, 0.0), alglm::vec3(0.0, -1.0, 0.0));
ubo.view[2] = alglm::lookAt(lightPos, lightPos + alglm::vec3(0.0, 1.0, 0.0), alglm::vec3(0.0, 0.0, 1.0));
ubo.view[3] = alglm::lookAt(lightPos, lightPos + alglm::vec3(0.0, -1.0, 0.0), alglm::vec3(0.0, 0.0, -1.0));
ubo.view[4] = alglm::lookAt(lightPos, lightPos + alglm::vec3(0.0, 0.0, 1.0), alglm::vec3(0.0, -1.0, 0.0));
ubo.view[5] = alglm::lookAt(lightPos, lightPos + alglm::vec3(0.0, 0.0, -1.0), alglm::vec3(0.0, -1.0, 0.0));
ubo.proj = lightProj;

shadowCubeMapUniformBuffersSSBO[shadowMapIndex][currentFrame]->updateUniformBuffer(&ubo, sizeof(ubo));

vkCmdBindDescriptorSets(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, shadowCubeMapPipelineLayoutSSBO, 0, 1,
                        &shadowCubeMapDescriptorSetsSSBO[shadowMapIndex][currentFrame], 0, nullptr);

ShadowCubeMapPushConstants pushConstants{};
for (uint32_t i = 0; i < 6; i++)
{
    uint32_t modelIndex = 0;
    pushConstants.layerIndex = i;
    vkCmdPushConstants(commandBuffer, shadowCubeMapPipelineLayoutSSBO, VK_SHADER_STAGE_VERTEX_BIT, 0,
                       sizeof(pushConstants), &pushConstants);
    for (auto &modelKeyValue : m_shadowMapModels[currentFrame])
    {
        auto &model = m_modelsMap[modelKeyValue.first];
        model->drawShadowSSBO(commandBuffer, modelKeyValue.second.size(), modelIndex);
        modelIndex += modelKeyValue.second.size();
    }
}

이렇게 바꿔주면서 각 면을 렌더링 하면 끝!

 

이런식으로 하면 mesh별로는 draw호출할지언정 object 개수만큼 draw호출하진 않는다

 

shadowmap을 그리기위해 draw호출 횟수이다.

보면 두번인데 이건 구랑 평면이 있어서 두개다!

 

예전에는 오브젝트 1000개면 1000번 draw 호출하고 point light이면 6개면을 다 draw호출해야하니 6000번이 불렸었다..

 

 

다음은 defered rendering descriptorset을 줄이기 위해 어떤 고민을 하고 있는지 써봐야겠다.

계속 실패하는데 어느정도는 가닥이 잡힌듯하다.