zero_copy_video_player/CUDA_GL_INTEROP.md
2026-08-05 08:56:00 -03:00

5.3 KiB

Guía Técnica: Interoperabilidad CUDA-GL para Renderizado Zero-Copy (GPU NVIDIA)

Esta guía explica la arquitectura y detalles técnicos de la solución de Zero-Copy real (GPU-to-GPU) implementada en este proyecto C++/Qt6 (Debian 13 con tarjeta gráfica NVIDIA).


1. El Desafío Arquitectónico en NVIDIA

En un reproductor de video acelerado tradicional, el flujo de datos suele sufrir un cuello de botella en el bus PCIe:

[Disco Duro] ──> [CPU / RAM] ──> [GPU (Decodifica)] ──> [CPU / RAM (Descarga)] ──> [GPU (Textura OpenGL)]

Este viaje de ida y vuelta a la memoria RAM principal satura la CPU debido al copiado de millones de píxeles por segundo (especialmente en resoluciones 4K).

Para evitar esto en tu máquina con GPU NVIDIA, implementamos Interoperabilidad CUDA-GL con FFmpeg nativo (NVDEC), logrando que el flujo sea puramente en la GPU:

[Disco Duro] ──> [CPU (Demuxing de comandos)]
                      │
                      ▼
               [GPU NVDEC (Decodifica en VRAM)] ──(Copia interna CUDA-GL)──> [Texturas OpenGL (VRAM)] ──> [Shader CRT] ──> [Monitor]

2. Los 4 Pasos Clave del Código C++

La interoperabilidad se logra conectando las APIs de CUDA y OpenGL a través de la librería libcuda.so de NVIDIA.

Paso A: Registro del Recurso Compartido

En la inicialización, creamos dos texturas OpenGL vacías (m_texY para luminancia y m_texUV para crominancia) y las registramos en CUDA usando la función cudaGraphicsGLRegisterImage:

cudaGraphicsGLRegisterImage(&m_cudaResourceY, m_texY, GL_TEXTURE_2D, cudaGraphicsRegisterFlagsWriteDiscard);
cudaGraphicsGLRegisterImage(&m_cudaResourceUV, m_texUV, GL_TEXTURE_2D, cudaGraphicsRegisterFlagsWriteDiscard);

Esto le dice al driver de NVIDIA que reserve una "dirección de memoria compartida" que tanto CUDA como OpenGL pueden entender.

Paso B: Mapeo y Bloqueo de Recursos

Durante el renderizado de cada fotograma (paintGL()), debemos asegurar que OpenGL no intente leer de las texturas mientras CUDA escribe en ellas. Bloqueamos los recursos mediante:

cudaGraphicsMapResources(1, &m_cudaResourceY, 0);
cudaGraphicsMapResources(1, &m_cudaResourceUV, 0);

Paso C: Copia VRAM-to-VRAM (Device-to-Device)

Extraemos los manejadores de arrays internos de CUDA vinculados a nuestras texturas OpenGL y ejecutamos una copia directa por hardware:

cudaArray_t arrayY, arrayUV;
cudaGraphicsSubResourceGetMappedArray(&arrayY, m_cudaResourceY, 0, 0);
cudaGraphicsSubResourceGetMappedArray(&arrayUV, m_cudaResourceUV, 0, 0);

// Copias internas de memoria física en la VRAM
cudaMemcpy2DToArray(arrayY, 0, 0, (void*)frame->data[0], frame->linesize[0], w, h, cudaMemcpyDeviceToDevice);
cudaMemcpy2DToArray(arrayUV, 0, 0, (void*)frame->data[1], frame->linesize[1], w, h / 2, cudaMemcpyDeviceToDevice);

La bandera cudaMemcpyDeviceToDevice garantiza que la GPU use su controlador DMA interno en VRAM. La CPU solo envía las coordenadas de inicio y fin, pero no toca un solo píxel.

Paso D: Desmapeo

Liberamos las texturas para que OpenGL vuelva a tomar el control y dibuje la escena:

cudaGraphicsUnmapResources(1, &m_cudaResourceY, 0);
cudaGraphicsUnmapResources(1, &m_cudaResourceUV, 0);

3. Resolviendo tus Problemas Específicos

Durante el desarrollo de esta integración en tu máquina, solucionamos dos bugs críticos de bajo nivel:

Caso A: La imagen cabeza abajo (Inversión vertical de origen)

  • Problema: OpenGL posiciona el origen (0,0) en la esquina inferior izquierda. CUDA lo posiciona arriba a la izquierda. El video se veía al revés.
  • Solución: Modificamos el Vertex Buffer (VBO) en C++ para invertir el eje vertical (V) de las coordenadas de textura sin costo de procesamiento:
    • Fila inferior: Coordenada de textura V = 1.0 (en lugar de 0.0)
    • Fila superior: Coordenada de textura V = 0.0 (en lugar de 1.0)

Caso B: La mitad derecha de la pantalla verde (Ancho de plano UV en NV12)

  • Problema: El formato decodificado es NV12. El plano UV (color) tiene la mitad del ancho de píxeles del video (W/2). Pero al estar U y V entrelazados (2 bytes por píxel), el ancho físico en bytes de cada fila del buffer de color es 2 \times (W / 2) = W bytes.
  • El bug: Originalmente le pasábamos w / 2 (el conteo de píxeles) a cudaMemcpy2DToArray. Al exigir esta función el ancho en bytes, la GPU solo copiaba la mitad izquierda de la información de color. La mitad derecha quedaba vacía (0.0), lo cual en la matriz de conversión YUV-a-RGB de tu fragment shader se traduce en un tono verde intenso.
  • Solución: Cambiamos el parámetro de ancho a w (ancho completo en bytes), rellenando la textura de croma completa.

4. Beneficios Obtenidos en tu Sistema

  1. Carga del Procesador (CPU) de 0%: El procesador de tu sistema queda totalmente libre para otras tareas de tu aplicación principal.
  2. Latencia Cero: Al evitar el cuello de botella del bus PCIe, no hay retraso (stuttering) al reproducir archivos de altos bitrates o resoluciones 4K/8K.
  3. Procesado de Shaders Directo: Dado que la textura se actualiza directamente en OpenGL, los filtros CRT y la curvatura de lente se aplican instantáneamente al renderizar sobre la pantalla del reproductor.