TL;DR —
renderFinished세마포어를 frame slot으로 인덱싱하면, present가 아직 사용 중인 세마포어를 다시 signal하려는 에러가 발생합니다. fence는 submit 완료만 보장하고 present 완료는 보장하지 않기 때문입니다.renderFinished는 스왑체인 이미지 인덱스로,imageAvailable은 frame slot으로 관리하면 해결됩니다.
Table of contents
Open Table of contents
들어가며
Emberlit 프로젝트에서 frames-in-flight를 스왑체인 이미지 수(3)에서 2로 줄였더니 Validation Layer 에러가 발생했습니다. 이 글에서는 에러의 원인을 추적하면서 fence와 semaphore가 각각 뭘 보장하는지, 그리고 세마포어를 어떤 기준으로 관리해야 하는지를 정리합니다.
이 글에서 다루는 내용:
- Vulkan 프레임 렌더링 흐름과 3개의 actor (CPU, GPU, 프레젠트 엔진)
- Fence와 Semaphore가 각각 보장하는 것과 보장하지 않는 것
renderFinished를 frame slot으로 인덱싱하면 깨지는 이유- 해결: 이미지 인덱스 기반 관리
사전 지식: Vulkan의 스왑체인, 커맨드 버퍼 제출 흐름에 대한 기본 이해를 전제로 합니다.
1. 문제 상황
frames-in-flight를 2로 줄였더니 다음 에러가 발생했습니다.
vkQueueSubmit(): pSubmits[0].pSignalSemaphores[0] is being signaled by VkQueue,
but it may still be in use by VkSwapchainKHR.
“세마포어가 아직 스왑체인에서 사용 중인데 다시 signal하려 한다.” 이 에러를 이해하려면 프레임 렌더링 흐름에서 fence와 semaphore가 각각 뭘 보장하는지 알아야 합니다.
2. 프레임 렌더링 흐름
한 프레임의 흐름은 다음과 같습니다.
1. vkWaitForFences(inFlight[frame]) — 이전 프레임의 GPU 작업 완료 대기
2. vkAcquireNextImageKHR(sem = imageAvailable[frame], &imageIndex) — 이미지 획득
3. vkQueueSubmit(wait = imageAvailable[frame],
signal = renderFinished[?],
fence = inFlight[frame]) — 렌더링 명령 제출
4. vkQueuePresentKHR(wait = renderFinished[?]) — 화면 표시
이 흐름에서 actor는 3개입니다.
- CPU: Vulkan 함수를 호출하여 작업을 등록합니다.
- graphics queue (GPU):
vkQueueSubmit으로 들어온 커맨드 버퍼를 실행합니다. - presentation engine / WSI: 스왑체인 이미지를 화면에 표시하고, 다 쓰면 다시 acquire 가능 상태로 돌려줍니다.
3. Fence — CPU ↔ GPU submit 동기화
vkQueueSubmit에 연결됩니다. graphics queue가 해당 submit batch의 실행을 끝내면 signal됩니다. CPU는 vkWaitForFences로 기다릴 수 있습니다.
vkQueueSubmit(queue, 1, &submitInfo, m_inFlight[m_currentFrame]);
// 이 fence가 signal되는 시점 = 이 submit batch의 GPU 실행 완료
vkQueuePresentKHR(presentQueue, &presentInfo);
// 기본 경로에는 present 완료를 알려주는 fence가 없다
fence가 보장하는 것: “같은 frame slot의 이전 submit batch는 GPU에서 끝났다”
fence가 보장하지 않는 것: “그 submit 결과를 사용한 present까지 끝났다”
즉 fence는 submit의 lifetime을 추적하지, swapchain image의 present lifetime까지 추적하지 않습니다.
4. Semaphore — GPU ↔ GPU 동기화
바이너리 세마포어의 규칙:
- signal: unsignaled → signaled (이미 signaled면 에러)
- wait: signaled → unsignaled
4.1 imageAvailable — presentation engine → graphics queue
vkAcquireNextImageKHR(device, swapchain, UINT64_MAX,
m_imageAvailable[currentFrame],
VK_NULL_HANDLE,
&m_imageIndex);
vkAcquireNextImageKHR는 성공 시 이미지 인덱스를 리턴합니다. 하지만 이것만으로는 해당 이미지가 사용 가능하다는 의미가 아닙니다. 실제로 프레젠트 엔진이 해당 이미지 사용을 끝내고 imageAvailable 세마포어 또는 acquire에 넘긴 fence를 signal해야 안전하게 렌더링할 수 있습니다.
- acquire 리턴 = “다음에 쓸 이미지 번호를 알려줌”
imageAvailable또는 acquire fence signal = “프레젠트 엔진이 이 이미지를 놓아줌, 이제 렌더링해도 됨”
vkQueueSubmit에서 이 세마포어를 wait한 후 렌더링을 시작합니다.
4.2 renderFinished — graphics queue → presentation engine
vkQueueSubmit이 signal합니다.vkQueuePresentKHR가 wait합니다.- 의미: “렌더링이 끝났으니 present를 시작해도 된다”
중요: present는 비동기입니다. vkQueuePresentKHR를 호출하면 즉시 리턴하고, 실제 세마포어 wait + 화면 표시는 나중에 프레젠트 엔진이 처리합니다. 그래서 present operation이 renderFinished의 semaphore payload를 언제 완전히 소비하고 더 이상 참조하지 않는지 CPU가 직접 알 수 없습니다.
5. 왜 에러가 나는가
renderFinished를 frame slot(frames-in-flight = 2)으로 인덱싱하면:
Frame 0 (frame=0): image 0 → renderFinished[0]으로 present
Frame 1 (frame=1): image 1 → renderFinished[1]으로 present
Frame 2 (frame=0): image 2 → renderFinished[0]을 다시 signal하려 함
Frame 2에서 fence wait은 Frame 0의 커맨드 버퍼 실행 완료를 보장합니다. 하지만 Frame 0의 present operation이 renderFinished[0]을 더 이상 참조하지 않는지는 보장하지 않습니다. present에는 기본 경로에서 fence가 없기 때문입니다.
Frame 2에서 acquire한 이미지는 image 2이지, image 0이 아닙니다. acquire가 성공해서 image 2를 반환했다는 사실만으로는 image 2가 이미 즉시 안전하게 사용 가능하다는 뜻도 아닙니다. 실제 보장은 acquire에 넘긴 semaphore 또는 fence가 signal된 시점에 생깁니다.
여기서 acquire completion은 vkAcquireNextImageKHR에 넘긴 semaphore 또는 fence가 signal된 시점을 뜻합니다.
acquire completion이 보장하는 것은:
- “image 2에 대한 이전 present/use가 끝났고, 이제 앱이 image 2를 다시 사용할 수 있다”
하지만 여전히 보장하지 않는 것은:
- “image 0의 present/use가 끝났다”
- “따라서 renderFinished[0]이 더 이상 presentation engine에 의해 사용 중이 아니다”
좀 더 직관적으로 보면 이렇습니다.
- Frame 0에서
renderFinished[0]은 image 0을 present하기 위해 사용되었습니다. - 그런데 Frame 2에서는 현재 acquire한 대상이 image 2입니다.
- 즉 Frame 2의 submit은
imageAvailable을 통해 “image 2는 안전하게 다시 써도 된다”는 것만 보장받습니다. - 하지만 같은 submit에서 다시 signal하려는
renderFinished[0]은 여전히 image 0의 이전 present operation에 묶여 있을 수 있습니다. - 그래서 “이번 프레임이 image 2를 쓴다”는 사실과 “renderFinished[0]을 다시 signal해도 된다”는 사실은 전혀 다른 문제입니다.
핵심: 세마포어(renderFinished[0])와 이미지(image 0)의 쌍이 엇갈려서, acquire의 보장이 세마포어까지 커버하지 못합니다.
6. 해결: renderFinished를 이미지별로 관리
imageAvailable[frameIndex] — frames-in-flight 개수 (2) — fence가 보호
renderFinished[imageIndex] — swapchain image 개수 (3) — 같은 image의 acquire completion이 간접적으로 재사용 시점을 보장
이렇게 하면 세마포어와 이미지가 항상 같은 쌍으로 움직입니다.
Frame 0: image 0 → renderFinished[0]
Frame 1: image 1 → renderFinished[1]
Frame 2: image 2 → renderFinished[2]
Frame 3: image 0 다시 acquire, acquire semaphore/fence signal 이후 → renderFinished[0] 재사용
왜 안전한가: image 0을 다시 acquire했고, 그 acquire에 넘긴 semaphore 또는 fence가 signal되었다는 것은 image 0의 이전 present/use가 끝났다는 뜻입니다. renderFinished[0]은 항상 image 0의 present와만 짝을 이루므로, 이 시점에서 재사용해도 안전합니다.
imageAvailable은 왜 frame slot으로 괜찮은가
imageAvailable[frame]은 vkQueueSubmit의 wait에서 소비됩니다. submit에는 fence가 연결되어 있으므로, 다음 프레임에서 같은 frame slot의 fence wait이 끝나면 → submit도 끝남 → imageAvailable도 이미 소비됨 → 안전하게 재사용 가능합니다.
정리하며
핵심 요약:
| 동기화 객체 | 방향 | 인덱스 기준 |
|---|---|---|
| Fence | GPU submit → CPU | frame slot |
| imageAvailable | presentation engine → graphics queue | frame slot |
| renderFinished | graphics queue → presentation engine | swapchain image |
- acquire 리턴 ≠ 이미지 사용 가능:
imageAvailable세마포어 또는 acquire에 넘긴 fence가 signal되어야 실제로 안전합니다. - acquire 순서는 보장되지 않습니다: 0 → 1 → 2 → 0 고정 순서가 아닙니다. present mode나 WSI 구현에 따라 달라질 수 있습니다.
imageAvailable은 그것을 wait하는 submit batch의 완료를 fence로 추적할 수 있으므로 frame slot 기준으로 재사용할 수 있습니다.renderFinished는 present 완료를 기본 경로에서 직접 추적할 수 없으므로, 같은 image의 acquire completion을 기준으로 간접 확인하는 swapchain image 기준 관리가 안전합니다.
참고 자료
- Khronos: Swapchain Semaphore Reuse — 공식 가이드
- Sascha Willems Vulkan Examples — 올바른 구현 예시
- Godot PR #80566 — 같은 동기화 버그 수정 사례
이 게시물은 학습한 내용을 바탕으로 초안을 작성한 뒤, LLM의 도움을 받아 내용을 검수하고 다듬어 완성되었습니다.