Files
video-v1/vav2/docs/completed/android/Vulkan_Image_Tutorial.md

12 KiB

Android MediaCodec AV1 (Async Decoding) + ImageReader + Vulkan 의 조합에서 Image 접근에서 발생하는 데드락을 해소하려면 API 를 어떻게 정교하게 호출해야 하는지 예제코드를 중심으로 설명해줘.

✦ 안녕하세요! Android에서 MediaCodec (비동기), ImageReader, Vulkan을 연동할 때 발생하는 데드락 문제는 매우 흔하면서도 까다로운 주제입니다. 질문하신 내용의 핵심은 각기 다른 스레드에서 동작하는 하드웨어 컴포넌트(디코더, GPU) 간의 버퍼 소유권과 상태를 어떻게 정확하게 동기화하는가에 있습니다.

데드락이 발생하는 주된 원인과 이를 해결하기 위한 정교한 API 호출 흐름을 예제 코드 중심으로 설명해 드리겠습니다.

데드락이 발생하는 시나리오 (잘못된 접근)

  1. [MediaCodec 스레드] onOutputBufferAvailable 콜백이 호출됩니다.
  2. [MediaCodec 스레드] 디코딩된 데이터를 ImageReader의 Surface로 렌더링합니다 (mediaCodec.releaseOutputBuffer(index, true)).
  3. [ImageReader 스레드] onImageAvailable 콜백이 호출됩니다.
  4. [ImageReader 스레드] imageReader.acquireNextImage()로 Image 객체를 획득합니다.
  5. [ImageReader 스레드] 이 Image를 즉시 Vulkan 렌더링 스레드로 전달하고, 렌더링이 끝날 때까지 기다립니다 (Block).
  6. [Vulkan 렌더링 스레드] Image를 사용하여 렌더링을 수행합니다.
  7. [MediaCodec 스레드] 한편, MediaCodec은 다음 프레임을 계속 디코딩하여 ImageReader로 보내려고 합니다. 하지만 ImageReader의 모든 버퍼(Image)가 아직 close()되지 않았고, 5번 단계에서 스레드가 대기 중이므로 새 버퍼를 제공할 수 없습니다. MediaCodec은 ImageReader가 버퍼를 반납하기를 기다리며 멈춥니다 (Block).
  8. 결과: 데드락!
    • ImageReader 스레드는 렌더링이 끝나길 기다리고,
    • MediaCodec은 ImageReader가 버퍼를 비워주길 기다리는 순환 대기(Circular Wait) 상태에 빠집니다.

해결 전략: 디커플링(Decoupling)과 명시적 동기화(Explicit Synchronization)

핵심은 디코더 스레드와 렌더링 스레드를 완전히 분리하고, 두 스레드 간의 통신은 스레드 안전 큐(Thread-Safe Queue)를 통해서만 이루어지게 하는 것입니다. 절대로 한쪽 스레드가 다른 쪽의 작업 완료를 직접 기다려서는 안 됩니다.

아키텍처:

  1. MediaCodec 스레드: ImageReader로부터 Image를 받으면, 필요한 정보(AHardwareBuffer, SyncFence)를 추출하여 '작업 큐'에 넣고 즉시 리턴합니다. 절대 대기하지 않습니다.
  2. 스레드 안전 큐 (Thread-Safe Queue): 디코더 스레드가 생산한 '디코딩된 프레임 정보'를 저장합니다. 렌더링 스레드는 이 큐에서 정보를 소비합니다.
  3. Vulkan 렌더링 스레드: 메인 렌더링 루프에서 '작업 큐'를 확인합니다.
    • 새 프레임이 있으면 큐에서 꺼냅니다.
    • (가장 중요) 이전에 사용했던 프레임의 렌더링이 GPU에서 완료되었는지 Vulkan 펜스(VkFence)로 확인합니다.
    • GPU 작업이 완료되었다면, 이전 Image 객체를 close()하여 ImageReader에 버퍼를 반납합니다.
    • 이제 새로 받은 프레임 정보를 사용하여 Vulkan 렌더링 명령을 제출합니다.

예제 코드로 보는 정교한 API 호출법

아래는 Kotlin (Android App)과 C++ (Vulkan NDK)의 개념을 혼합한 의사 코드 스타일의 예제입니다. API 호출의 흐름에 집중해 주세요.

  1. 공유 데이터 구조 및 큐 정의

디코더와 렌더러 간에 주고받을 데이터 구조와 이들을 담을 큐를 정의합니다.

1 // 디코딩된 프레임 정보를 담을 데이터 클래스
2 data class DecodedFrame(
3     val image: Image, // ImageReader로부터 받은 Image 객체
4     val hardwareBuffer: HardwareBuffer, // Vulkan에서 사용할 버퍼
5     val presentationTimeUs: Long, // 프레임 PTS
6     var gpuCompleteFence: Long = 0L // (C++ 네이티브 포인터) 이 프레임의 렌더링 완료를 알릴 VkFence
7 )
8
9 // 스레드 안전 큐 (생산자-소비자 패턴)

10 val frameQueue: BlockingQueue = LinkedBlockingQueue(2) // 큐 크기는 ImageReader 버퍼 수와 비슷하게 설정

  1. MediaCodec 및 ImageReader 설정 (디코더 스레드)

ImageReader.OnImageAvailableListener가 핵심입니다. 이 콜백은 절대 블로킹되면 안 됩니다.

1 // ImageReader 리스너 설정
2 val imageReader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 3) // 버퍼 3개
3 imageReader.setOnImageAvailableListener({ reader ->
4     val image: Image? = try {
5         reader.acquireNextImage()
6     } catch (e: IllegalStateException) {
7         // 리더가 닫혔을 때 발생 가능
8         null
9     }

10 11 if (image != null) { 12 // 1. Image에서 HardwareBuffer 추출 13 val hardwareBuffer = image.hardwareBuffer 14 ?: run { 15 // HardwareBuffer를 지원하지 않는 경우, 여기서 처리 16 image.close() 17 return@setOnImageAvailableListener 18 } 19 20 // 2. DecodedFrame 객체 생성 21 val frame = DecodedFrame( 22 image = image, 23 hardwareBuffer = hardwareBuffer, 24 presentationTimeUs = image.timestamp 25 ) 26 27 // 3. 큐에 프레임을 넣는다. 큐가 꽉 찼으면 잠시 기다리지만(offer의 타임아웃), 28 // 렌더러가 제 역할을 한다면 이 경우는 거의 발생하지 않아야 함. 29 // offer는 즉시 리턴하므로 콜백을 블록하지 않음. 30 if (!frameQueue.offer(frame, 200, TimeUnit.MILLISECONDS)) { 31 // 큐에 넣기 실패 (렌더러가 심하게 느림). 프레임을 드롭하고 버퍼를 즉시 반환. 32 Log.w("Decoder", "Dropping frame, queue is full.") 33 image.close() 34 hardwareBuffer.close() 35 } 36 } 37 }, mediaCodecCallbackHandler) // 별도 핸들러 스레드에서 실행 38 39 // MediaCodec 콜백 설정 40 val codecCallback = object : MediaCodec.Callback() { 41 override fun onOutputBufferAvailable(codec: MediaCodec, index: Int, info: MediaCodec.BufferInfo) { 42 // 디코딩된 출력을 ImageReader의 Surface로 보낸다. 43 // 이 작업은 블로킹되지 않는다. 44 codec.releaseOutputBuffer(index, true) 45 } 46 // onInputBufferAvailable, onError, onOutputFormatChanged 등 구현... 47 } 48 49 mediaCodec.setCallback(codecCallback, mediaCodecCallbackHandler) 50 // ... 51 mediaCodec.configure(format, imageReader.surface, null, 0) 52 mediaCodec.start()

  1. Vulkan 렌더링 루프 (렌더링 스레드)

렌더링 스레드는 큐에서 프레임을 가져와 처리하고, 다 쓴 이전 프레임의 버퍼를 반납하는 역할을 담당합니다.

1 // 네이티브 C++ Vulkan 렌더러 의사 코드
2
3 // 현재 화면에 그리고 있거나, 그리기가 끝난 프레임 정보
4 DecodedFrame* pCurrentFrame = nullptr;
5
6 void renderLoop() {
7     while (true) {
8         // 1. 큐에서 새로운 프레임을 가져온다 (블로킹 대기)
9         DecodedFrame* pNewFrame = frameQueue.take(); // Java의 큐에서 JNI를 통해 가져옴

10 11 // 2. (가장 중요) 이전에 렌더링했던 프레임이 있다면, GPU 작업이 끝났는지 확인하고 리소스를 해제한다. 12 if (pCurrentFrame != nullptr) { 13 // 2a. 이전 프레임 렌더링에 사용된 VkFence가 signaled 상태가 될 때까지 기다린다. 14 // 타임아웃을 길게 설정하여 GPU가 완료할 시간을 충분히 준다. 15 vkWaitForFences(vkDevice, 1, &pCurrentFrame->gpuCompleteFence, VK_TRUE, UINT64_MAX); 16 17 // 2b. GPU가 이전 프레임의 hardwareBuffer 사용을 마쳤으므로, 18 // VkFence와 관련 Vulkan 리소스를 파괴한다. 19 vkDestroyFence(vkDevice, pCurrentFrame->gpuCompleteFence, nullptr); 20 destroyVulkanImageAndMemoryForBuffer(pCurrentFrame->hardwareBuffer); // 이전에 생성한 VkImage/VkDeviceMemory 해제 21 22 // 2c. (데드락 해소의 핵심) Java의 Image 객체를 close()하여 ImageReader에 버퍼를 반납한다. 23 // JNI를 통해 Java의 image.close()와 hardwareBuffer.close() 호출 24 releaseJavaFrameObjects(pCurrentFrame); 25 26 delete pCurrentFrame; 27 } 28 29 // 3. 새로운 프레임을 현재 프레임으로 설정 30 pCurrentFrame = pNewFrame; 31 32 // 4. 새로운 프레임의 AHardwareBuffer를 Vulkan 리소스로 변환(Import)한다. 33 // - VK_ANDROID_external_memory_android_hardware_buffer 확장이 필요. 34 VkImage vkImage = importHardwareBufferToVkImage(pCurrentFrame->hardwareBuffer); 35 VkDeviceMemory vkMemory = importHardwareBufferToVkDeviceMemory(pCurrentFrame->hardwareBuffer); 36 vkBindImageMemory(vkDevice, vkImage, vkMemory, 0); 37 // ... VkImageView, VkSampler 생성 ... 38 39 // 5. 렌더링 완료를 추적할 새로운 VkFence를 생성한다. 40 VkFenceCreateInfo fenceInfo = { VK_STRUCTURE_TYPE_FENCE_CREATE_INFO }; 41 vkCreateFence(vkDevice, &fenceInfo, nullptr, &pCurrentFrame->gpuCompleteFence); 42 43 // 6. Vulkan 커맨드 버퍼를 기록하고 제출(submit)한다. 44 // - YUV to RGB 변환 셰이더 등을 사용 45 VkCommandBuffer cmd = recordCommandBuffer(vkImage); 46 47 VkSubmitInfo submitInfo = { ... }; 48 submitInfo.pCommandBuffers = &cmd; 49 // 디코더가 버퍼 쓰기를 완료했음을 GPU에 알리는 동기화는 VkExternalMemoryAcquireUnmodifiedEXT 등을 사용하거나, 50 // Android 13 (API 33) 이상에서는 MediaCodec/Image에서 받은 SyncFence를 Vulkan Semaphore로 변환하여 51 // pWaitSemaphores에 추가하는 것이 더 정교한 방법입니다. 52 53 // 커맨드 제출 시, 작업이 끝나면 pCurrentFrame->gpuCompleteFence를 signal하도록 설정 54 vkQueueSubmit(vkGraphicsQueue, 1, &submitInfo, pCurrentFrame->gpuCompleteFence); 55 56 // ... 스왑체인에 Present ... 57 } 58 }

요약 및 핵심 포인트

  1. 역할 분리: 디코더 스레드는 생산자 역할만, 렌더링 스레드는 소비자 역할만 수행합니다.
  2. 비동기 통신: 두 스레드 간의 통신은 오직 스레드 안전 큐를 통해서만 이루어집니다. 콜백 함수 안에서 절대 wait, sleep, lock 등 대기 작업을 수행하지 마세요.
  3. 버퍼 반납 시점: Image.close()는 GPU가 해당 버퍼(Image)를 사용한 렌더링을 완전히 마친 후에 호출해야 합니다. 이 시점을 아는 유일한 방법은 vkQueueSubmit에 전달한 VkFence를 기다리는 것입니다.
  4. 자원 관리: pCurrentFrame 변수처럼, 현재 렌더링 중인 프레임과 다음 루프에서 해제해야 할 프레임을 명확히 구분하여 관리해야 합니다.
  5. 동기화 객체:
    • VkFence: CPU(렌더링 스레드)가 GPU 작업 완료를 기다릴 때 사용합니다.
    • SyncFence (API 33+): Image.getHardwareBuffer().getSyncFence()로 얻을 수 있으며, 디코더가 버퍼에 쓰기를 완료했음을 보장합니다. 이를 Vulkan VkSemaphore로 변환하여 vkQueueSubmit의 pWaitSemaphores에 전달하면, GPU가 디코더 작업을 기다리게 할 수 있어 가장 이상적인 동기화가 가능합니다.

이 아키텍처를 따르면 MediaCodec, ImageReader, Vulkan이 각자의 파이프라인을 막힘없이 효율적으로 실행할 수 있어 데드락을 원천적으로 방지하고 최적의 성능을 얻을 수 있습니다.