2022. 6. 28. 11:46ㆍVulkan
개요
삼각형을 처음 만들기 직전에 파이프라인을 만들 때 몇 가지 동적으로 바꿀 수 있게끔 구성할 수 있다고 하며 이 문서로 연결해 드린 적이 있습니다. 1.0 버전을 기준으로 뷰포트/시저/선 굵기/깊이 바이어스/블렌드 상수/깊이 경계값/스텐실 마스크/스텐실 참조값이 그렇게 설정 가능한데, 여기서는 뷰포트와 시저를 런타임에 조정할 수 있게 해 보겠습니다.
목차
1. 스왑체인 재생성
3. 동적 조절
본문
1. 스왑체인 재생성
뷰포트를 바꿀 일은 창 크기를 바꾸는 경우에나 필요할 일입니다. 하지만 지금은 창 크기를 바꿀 준비가 안 돼 있습니다.
프로그램을 틀어서 한 번 최소화해 봅시다. 콘솔은 vkAcquireNextImageKHR의 호출에서 실패했다는 의미의 Fail 1로 가득 찰 겁니다. 이것은 창을 다시 원래대로 되돌려도 복구가 되지 않습니다. 창 시스템에서 받을 수 있는 프레임버퍼 크기가 0 x 0으로 바뀌었기 때문입니다. 이렇게 한 번 바뀌면 스왑체인이 성능이 애매해지거나(suboptimal) 더 이상 사용할 수 없게 됩니다(out of date).
많은 게임에서는 자체 기능으로 게임 내에서의 특정 해상도로의 변경이 그 모니터에서 가능하면 지원하고, 어떤 게임은 창 크기를 자유롭게 변경하면 뷰포트를 알아서 거기에 맞춰 줍니다. 벌칸에서 이 중 하나를 하려면 스왑 체인을 재생성해야 합니다. 스왑 체인에 종속성이 있는 프레임버퍼와 첨부물, 렌더패스와 파이프라인까지 모두 다시 만들어야 합니다. 이 글을 읽는 사람 중 알렉산더님의 이 글을 따라 스왑체인을 재생성한 경험이 있다면 그 코드가 재조절 프레임마다 스왑체인을 재생성해 버리는 바람에 아주 오래 걸리는 작업인 걸 느끼셨을 겁니다.
스왑체인도, 파이프라인도 재생성 시 이전의 것을 입력할 수는 있지만 당장에는 빠르게 배우기 위해 그건 포기하고 당장의 코드를 간단히 작성하는 방향으로 갑니다.
창 크기가 변하는 것과 같은 다양한 이벤트는 glfwPollEvents에서 인식하여 수행합니다. 여기서 수행 후 할 일에 대하여 함수 포인터를 넘겨 별도의 콜백을 지정할 수 있습니다. 여기선 일단 프레임버퍼 리사이즈 콜백을 지정합니다.
// VkPlayer.h
static void onResize(GLFWwindow* window, int width, int height);
static bool resizing, shouldRecreateSwapchain; // 초기값 false
// VkPlayer:: createWindow
...
glfwSetFramebufferSizeCallback(window, onResize);
return true;
스왑체인 관련 요소를 재생성하기 위한 플래그를 일단 콜백 안에 그대로 나열해 봅시다.
void VkPlayer::onResize(GLFWwindow* window, int width, int height) {
resizing = true;
shouldRecreateSwapchain = true;
}
그 다음 메인 루프를 수정합니다.
// VkPlayer::mainLoop
for (frame = 1; glfwWindowShouldClose(window) != GLFW_TRUE; frame++) {
glfwPollEvents();
if (resizing) {
resizing = false;
continue;
}
if (shouldRecreateSwapchain) {
vkDeviceWaitIdle(device);
destroyDescriptorSet();
destroyPipelines();
destroyFramebuffers();
destroyDSBuffer();
destroySwapchainImageViews();
destroySwapchain();
createSwapchain();
createSwapchainImageViews();
createDSBuffer();
createFramebuffers();
createDescriptorSet();
createPipelines();
shouldRecreateSwapchain = false;
}
...
기술자 집합을 다시 만드는 이유는 DSBuffer의 이미지와 이미지 뷰를 다시 만들기 때문입니다. 원래는 디스크립터 업데이트만 해 주면 되지만, 지금은 간단히 이렇게만 하고 넘어가고, 이후 모듈화 시에 정리하겠습니다. 렌더버퍼의 경우 이미지 포맷이 스왑체인에 종속적인데 이는 장치에서 사용 가능한 한 변할 이유가 없어 굳이 하지 않았습니다.
창 리사이즈에 제대로 대응할 수 있는지 확인하기 위해 창 크기 변경을 허용해 봅시다. 창 생성 전에 glfwWindowHint를 이렇게 주면 됩니다.
// VkPlayer::createWindow
glfwWindowHint(GLFW_RESIZABLE, GLFW_TRUE);
이제 창 리사이즈에 대응할 수는 있습니다. 컴파일하고 프로그램을 실행하고 크기를 바꿔 보죠.
![]() |
![]() |
잘 되는군요. 그럼 최소화해 볼까요? 그러면 아마 확인 계층에서 대충 0은 안 된다고 얘기할 겁니다. 프로그램이 깨지지는 않고 창을 다시 복원하면 그대로 프로그램이 돌아가지만 혹시 장치마다 어떻게 될지는 모르는 일이죠? 그래서 사실 루프에서는 0이면 렌더링 하지 말고 대기시켜야 합니다. 게임 프레임 업데이트는 게임마다 시킬 수도 있고 안 시킬 수도 있으니, 이런 식으로 해야 맞겠죠.
poll window event - game frame update - (potential swap chain recreation) - render
하지만 지금은 어차피 시작 단계고 프레임 업데이트랑 렌더랑 섞여 있으니 그냥 창 표면 크기가 0이 되면 그 상태를 벗어날 때까지 프로그램을 대기시키기로 합시다.
// VkPlayer::mainLoop
if (shouldRecreateSwapchain) {
int width = 0, height = 0;
do {
glfwGetFramebufferSize(window, &width, &height);
glfwWaitEvents();
} while (width == 0 || height == 0);
...
이제 창을 최소화해도 오류 알림은 안 나옵니다.
2. 뷰포트의 종횡비를 유지하기
여기 부분은 벌칸과 직접적 관련이 없습니다. 종횡비 유지 쯤은 쉽다면 넘어가셔도 좋습니다.
지금은 창의 크기를 바꿀 수 있지만 뭔가 부자연스럽습니다. 종횡비를 보정하고 있는 행렬이 있긴 한데 창 크기를 어떻게 하냐에 따라 보이는 게임 속 영역이 크게 다르죠. 당장 저 위의 사진만 봐도 그렇습니다. 이제 전에 말씀드린 바와 같이 뷰포트 종횡비를 고정하고, 종횡 중 짧은 쪽에 맞추겠습니다.
먼저 종횡비 상수를 정의해 줍시다.
int rwid = 16, rheight = 9;
이걸 런타임에 바꿀 수 있게 할지 없게 할지는 여러분 몫이겠죠. 아무튼 이 값은 이런 식으로 사용하면 됩니다.
// VkPlayer::createPipeline0
...
VkViewport viewport{};
viewport.minDepth = 0.0f;
viewport.maxDepth = 1.0f;
float r = (float)rwid / rheight;
if (swapchainExtent.width * rheight < swapchainExtent.height * rwid) {
viewport.x = 0.0f;
viewport.width = (float)swapchainExtent.width;
viewport.height = swapchainExtent.width / r;
viewport.y = (swapchainExtent.height - viewport.height) * 0.5f;
}
else {
viewport.y = 0.0f;
viewport.height = (float)swapchainExtent.height;
viewport.width = swapchainExtent.height * r;
viewport.x = (swapchainExtent.width - viewport.width) * 0.5f;
}
...
if문의 조건은 뷰포트를 창의 가로에 맞춰야 할 때입니다. 어렵게 생각할 거 없습니다. 화면 크기가 종 10: 횡 1이고 종 2: 횡 1 직사각형을 그 가운데에 꼭 맞게 채우고 싶다면 당연히 가로를 꽉 채워야 할 테니까요. 그 다음 종횡비를 고정했으니 회전 + 종횡비 보정 행렬도 그에 맞게 바꿔야 합니다.
float asp = (float)rheight / rwid;
이제 프로그램을 실행하면 초기에 화면이 가득 차지 않는 모습을 볼 수 있습니다. 대충은 원하는 대로 됐지만 아직 한 가지 부족한데, 0번 서브패스에서는 건드리지 않았던 조각까지 1번 서브패스에서 반전시키고 있습니다. 상관이 있는 때도 있을 거고 없는 때도 있을 테지만 조각 셰이더가 쓸데 없이 일을 더 하고 있기도 하죠. 여기선 가위를 조정해 줍니다. 방법은 위와 같겠죠?
// VkPlayer::createPipeline1
...
VkRect2D scissor{};
float r = (float)rwid / rheight;
if (swapchainExtent.width * rheight < swapchainExtent.height * rwid) {
scissor.extent.width = swapchainExtent.width;
scissor.extent.height = swapchainExtent.width * rheight / rwid;
scissor.offset.x = 0;
scissor.offset.y = int((swapchainExtent.height - scissor.extent.height) * 0.5f);
}
else {
scissor.extent.height = swapchainExtent.height;
scissor.extent.width = swapchainExtent.height * rwid / rheight;
scissor.offset.y = 0;
scissor.offset.x = int((swapchainExtent.width - scissor.extent.width) * 0.5f);
}
...
결과는 이런 식으로 나오면 됩니다. 전체 코드는 여기 있습니다.
![]() |
![]() |
3. 동적 조절
이제 이걸 동적으로 조절할 겁니다. 앞에서 했던 내용을 원래대로 되돌리고 동적 기능으로 추가하여 결과가 같은지를 확인하면 되겠네요.
잠깐, 이걸 꼭 해야 하나? 지금 잘 되고 있잖아. | ||
지금 파이프라인을 재생성하고 있는 이유를 떠올려 볼까? | ||
순수 뷰포트랑 가위 때문이야. 다만 지금은 코드를 간단히 하기 위해 기술자 집합을 다시 만들고 있으니 뷰포트 가위를 동적으로 바꾼다 해도 오류가 나겠지. | ||
파이프라인이 지금 2개지. 아마 프로그램이 복잡해진다면 더 많은 수의 셰이더를 컴파일해야 할 거고 시간을 제법 잡아먹을 거야. 그게 가능한 처음부터 다시 만들기를 피해야 할 이유지. | ||
그런데 동적 상태가 늘어나면 그만큼 최적화할 소지가 줄어드는 거 아니야? | ||
그걸로는 최적화가 유의미하게 제한되지 않는 걸로 알려져 있더라. |
생성 시 뷰포트/가위 세팅을 원래대로 되돌리는 일은 여기선 생략합니다.
이들을 동적 상태로 만들기 위해 dynamic state 설정에서 각각 뷰포트, 가위를 등록합니다.
// VkPlayer::createPipeline0
...
VkDynamicState dynamicStates[1] = { VK_DYNAMIC_STATE_VIEWPORT };
VkPipelineDynamicStateCreateInfo dynamics{};
dynamics.sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO;
dynamics.dynamicStateCount = sizeof(dynamicStates) / sizeof(dynamicStates[0]);
dynamics.pDynamicStates = dynamicStates;
...
pipelineInfo.pDynamicState = &dynamics;
...
// VkPlayer::createPipeline1
...
VkDynamicState dynamicStates[1] = { VK_DYNAMIC_STATE_SCISSOR };
VkPipelineDynamicStateCreateInfo dynamics{};
dynamics.sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO;
dynamics.dynamicStateCount = sizeof(dynamicStates) / sizeof(dynamicStates[0]);
dynamics.pDynamicStates = dynamicStates;
...
pipelineInfo.pDynamicState = &dynamics;
...
이렇게 하면 앞에서 주었던 크기 등의 정보는 무시됩니다. 그리기 명령이 있기 전에 반드시 이 정보를 한 번은 넘겨야 합니다. 그 명령은 각각 vkCmdSetViewport와 vkCmdSetScissor입니다. 나머지 동적 상태도 이런 식으로 정할 수 있습니다. 정확한 사용법은 여기를 참고하세요.
이 뷰포트 설정과 같은 동적 상태 설정 명령도 파이프라인 바인드 등과 같이 버퍼 상의 상태 명령입니다. 그래서 이걸 세팅하면 같은 명령 버퍼에서 관련 파이프라인 명령에 대하여 이 동적 상태가 쓰입니다. 즉, 이렇게 해 주세요.
// 어디가 됐든 미리 되어 있으면 좋을 부분
VkViewport viewport{};
viewport.minDepth = 0.0f;
viewport.maxDepth = 1.0f;
float r = (float)rwid / rheight;
if (swapchainExtent.width * rheight < swapchainExtent.height * rwid) {
viewport.x = 0.0f;
viewport.width = (float)swapchainExtent.width;
viewport.height = swapchainExtent.width / r;
viewport.y = (swapchainExtent.height - viewport.height) * 0.5f;
}
else {
viewport.y = 0.0f;
viewport.height = (float)swapchainExtent.height;
viewport.width = swapchainExtent.height * r;
viewport.x = (swapchainExtent.width - viewport.width) * 0.5f;
}
VkRect2D scissor{};
if (swapchainExtent.width * rheight < swapchainExtent.height * rwid) {
scissor.extent.width = swapchainExtent.width;
scissor.extent.height = swapchainExtent.width * rheight / rwid;
scissor.offset.x = 0;
scissor.offset.y = int((swapchainExtent.height - scissor.extent.height) * 0.5f);
}
else {
scissor.extent.height = swapchainExtent.height;
scissor.extent.width = swapchainExtent.height * rwid / rheight;
scissor.offset.y = 0;
scissor.offset.x = int((swapchainExtent.width - scissor.extent.width) * 0.5f);
}
// VkPlayer::fixedDraw
...
vkCmdBindPipeline(commandBuffers[commandBufferNumber], VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline0);
vkCmdSetViewport(commandBuffers[commandBufferNumber], 0, 1, &viewport);
...
vkCmdBindPipeline(commandBuffers[commandBufferNumber], VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline1);
vkCmdSetScissor(commandBuffers[commandBufferNumber], 0, 1, &scissor);
...
각 함수의 매개변수는 세팅할 뷰포트/가위의 시작 번호, 거기부터 세팅할 개수, 그리고 내용입니다. 그리 어렵지 않습니다. 시작 번호는 명령을 읽을 당시 버퍼에서 쓰기로 한 파이프라인에서, VkPipelineViewportStateCreateInfo에서 세팅한 뷰포트와 가위 카운트와 관련이 있습니다.
방금과 같은 결과가 나와야 합니다. 코드는 이렇습니다.
요약
뷰포트와 가위는 파이프라인 동적 상태 중 사용할 만한 가치가 있는 것 중 둘입니다. 이들이 변해야 할 때는 창 크기가 조절될 때이며, 그를 처리하기 위해 스왑체인부터 종속되는 것들을 재생성했습니다. 그리고 파이프라인 생성 시 이들을 동적으로 설정할 수 있게 등록해 두고, 명령 버퍼에서 그걸 세팅해 보았습니다.
과제
생각나면 추가하겠습니다.
'Vulkan' 카테고리의 다른 글
Vulkan - 11. 동적 공유(uniform) 버퍼 (0) | 2022.07.02 |
---|---|
Vulkan - 10. Vulkan Memory Allocator(VMA) (0) | 2022.06.29 |
Vulkan - 8. 여러 서브패스에 걸친 렌더 패스 (0) | 2022.06.26 |
Vulkan - 7. 텍스처 (0) | 2022.06.23 |
Vulkan - 6. 깊이와 스텐실 버퍼 (0) | 2022.06.21 |