< 返回版块

shiqifeng2000 发表于 2026-07-10 14:14

Tags:cuda, rust, surface, opengl

Crepe

背景:我需要设计一个可以嵌入到 Unity / Flutter 等任意支持surface的播放器的组件,用来解码 3 路以上视频,并将它们与音频无缝同步渲染。每路视频只是大屏幕的一部分,所以每一帧的渲染都必须精确对齐。

在这篇文章中,我想讨论一个能够渲染多路 MP4 视频的播放器设计。我知道有些系统(比如 macOS)提供了类似 AVVideoComposition 的 API。但我的目标不仅仅是“渲染”视频——我需要一个跨平台的解决方案,能够将解码后的 surface 共享给实际的渲染器,这样上层的跨平台播放器或应用就可以直接使用我生成的 surface。这些视频的分辨率在 4K+ 以上,帧率在 30 到 60 fps。

首先,解码工作流应该如何设计?

为了保证所有视频都能流畅解码,硬件加速解码是必须的。这意味着解码器输出的帧直接存放在 GPU 显存中。在渲染阶段,我们可以使用 OpenGL 或 DirectX 进行显示,因此显示容器也应该位于 GPU 上。解码和渲染是两个不同的阶段,而且往往涉及不同的格式——比如 CUDA 输出 NV12,而 OpenGL/DirectX 需要 RGB。通常的做法是:将解码后的 GPU 数据拷贝到 CPU 内存,转换格式,再拷回渲染阶段(OpenGL surface、DirectX shader 或 framebuffer)。这种往返拷贝之所以存在,是因为需要格式转换,或者需要使用 OpenCV 等 CPU 密集型工具。

                    Video Playback Pipeline

                ┌───────────────────────────┐
Compressed ---->| HW Decoder                |
H264/H265/AV1   | (NVDEC / VideoToolbox /   |
                | DXVA / VAAPI / MediaCodec)|
                └─────────────┬─────────────┘
                              │
                              │ Decoded Frame
                              ▼
                     GPU Memory (NV12/YUV420)

                              │
                              │ GPU → CPU Copy
                              ▼
                    +----------------------+
                    |     System Memory    |
                    |   NV12/YUV420 Frame  |
                    +----------------------+
                              │
             ┌────────────────┴─────────────────┐
             │                                  │
             │ CPU Color Conversion             │
             │ CPU Image Processing             │
             │ OpenCV / FFmpeg Filters          │
             │ AI Preprocessing                 │
             └────────────────┬─────────────────┘
                              │
                              │ RGB/BGRA
                              ▼
                    +----------------------+
                    |     System Memory    |
                    |      RGB Frame       |
                    +----------------------+
                              │
                              │ CPU → GPU Copy
                              ▼
                    GPU Texture / Surface
                              │
                              ▼
                    OpenGL / DirectX / Metal
                              │
                              ▼
                         Display Window

但是我们来算一笔账。一个 4K 视频在 60 fps 下,RGBA 数据吞吐量为 3840×2160×4×60 = 1990 MB/s。如果有 5 路视频同时播放,那就是 5 × 1990 = 9.9 GB/s。如果我们要从 GPU 拷到内存,再从内存拷回 surface,就是双向各 9.9 GB/s 的带宽消耗。当播放器因为 PCIe 总线带宽限制或 surface 上传瓶颈而卡顿时,这种方式就非常荒谬了。

PCIe 总线拷贝速度

PCIe Version Per-Lane Data Rate Encoding x1 Bandwidth x4 Bandwidth x8 Bandwidth x16 Bandwidth
PCIe 1.0 2.5 GT/s 8b/10b 250 MB/s 1.0 GB/s 2.0 GB/s 4.0 GB/s
PCIe 2.0 5.0 GT/s 8b/10b 500 MB/s 2.0 GB/s 4.0 GB/s 8.0 GB/s
PCIe 3.0 8.0 GT/s 128b/130b 985 MB/s 3.94 GB/s 7.88 GB/s 15.75 GB/s
PCIe 4.0 16.0 GT/s 128b/130b 1.97 GB/s 7.88 GB/s 15.75 GB/s 31.5 GB/s
PCIe 5.0 32.0 GT/s 128b/130b 3.94 GB/s 15.75 GB/s 31.5 GB/s 63.0 GB/s
PCIe 6.0 64.0 GT/s PAM4 + FEC 7.56 GB/s 30.25 GB/s 60.5 GB/s 121.0 GB/s
PCIe 7.0 (expected 2025) 128.0 GT/s PAM4 32 GB/s 128 GB/s 256 GB/s 512 GB/s

所以最好的方式是将解码后的视频数据直接从 GPU 拷贝到 surface——也就是所谓的零拷贝(Zero-Copy)。

流程大致如下:

Bitstream
    │
    ▼
NVDEC
    │
    ▼
NV12 Frame (pitched)
    │
    │  (shared or mapped)
    ▼
CUDA / Fragment Shader
    │
    │  YUV→RGB, scaling, denoise,
    │  crop, AI, tone mapping
    ▼
OpenGL Texture / Render Target
    │
    ▼
Swapchain
    │
    ▼
Display

这样一来,图像处理就完全在 GPU 内部完成了。在 CUDA 生态中,我需要引入 NPP 库来处理格式转换和其他图像处理任务。如果我们要对接 Unity3D,这一步是必须的,因为 Unity 提供的 surface 永远是 RGBA 格式。

NPP 不直接支持 RGBA——只支持 RGB。没关系,我们可以先用 nppiNV12ToRGB_8u_P2C3R_Ctx 做转换,然后用 cudaMemcpy2D 处理 pitch,最后每隔 4 个字节插入一个 1 作为 alpha 通道。

现在 surface 数据准备好了,怎么把它填到 surface 里呢?

CUDA 提供了方便的互操作 API。通过 cudaGraphicsGLRegisterImagecudaGraphicsD3D11RegisterImage,我们可以将 OpenGL/D3D 的 surface 注册为 CUDA 资源。然后使用 cudaGraphicsMapResourcescudaGraphicsSubResourceGetMappedArray 获取资源缓冲区的指针或数组,这样 surface 就可以接受帧数据了。

OpenGL / D3D Texture (Render Surface)
                 │
                 │ Register
                 │
                 ▼
      CUDA Graphics Resource
Compressed Stream
        │
        ▼
   HW Decoder
        │
        ▼
  NV12 Decode Frame
        │
        │ Map Resource
        ▼
   CUDA Kernel
 (NV12→RGB, Scale...)
        │
        ▼
 Registered GL/D3D Surface
        │
        ▼
   Render / Present

好了,现在一切就绪——但这只是单路视频的情况。

我们可以启动多个解码线程,注册多个 surface,然后独立渲染它们。大致如下:

                 One-time Initialization

+-----------+   +-----------+   +-----------+
| GL Tex #1 |   | GL Tex #2 |   | GL Tex #3 |
+-----+-----+   +-----+-----+   +-----+-----+
      |                 |                 |
 Register          Register          Register
      |                 |                 |
      v                 v                 v
 CUDA Resource1   CUDA Resource2   CUDA Resource3


                 Per-frame (Independent)

Video 1              Video 2              Video 3
--------             --------             --------

Bitstream1           Bitstream2           Bitstream3
     |                    |                    |
HW Decoder1          HW Decoder2          HW Decoder3
     |                    |                    |
NV12 Surface1        NV12 Surface2        NV12 Surface3
     |                    |                    |
Map Resource1        Map Resource2        Map Resource3
     |                    |                    |
CUDA Kernel1         CUDA Kernel2         CUDA Kernel3
     |                    |                    |
GL Texture1          GL Texture2          GL Texture3
     |                    |                    |
  Present1             Present2             Present3

这个设计当然能工作——但我们还需要同步

一个典型的视频播放器使用三种同步策略之一:

  1. 视频对齐到音频
  2. 音频对齐到视频
  3. 视频和音频都对齐到统一的时间线

我选择了第三种方案,因为我们的场景是多路视频流。每个视频文件包含相同的音频流,所以我选择第一个视频文件中的音频流,用 cpal 来播放。音频细节(如重采样)在这里就不展开了,因为本文主要讨论视频部分。

在引入同步之前,流程图是这样的:

                 One-time Initialization

+-----------+   +-----------+   +-----------+
| GL Tex #1 |   | GL Tex #2 |   | GL Tex #3 |
+-----+-----+   +-----+-----+   +-----+-----+
      |                 |                 |
 Register          Register          Register
      |                 |                 |
      v                 v                 v
 CUDA Resource1   CUDA Resource2   CUDA Resource3


                 Per-frame (Independent thread)

Video 1             Video 2             Video 3             Audio
--------            --------            --------            -----

Bitstream1          Bitstream2          Bitstream3         Bitstream4
     |                   |                   |                  |
HW Decoder1         HW Decoder2         HW Decoder3        Audio Demux
     |                   |                   |                  |
NV12 Surface1       NV12 Surface2       NV12 Surface3      Audio Frames
     |                   |                   |                  |
Map Resource1       Map Resource2       Map Resource3        Decode
     |                   |                   |                  |
CUDA Kernel1        CUDA Kernel2        CUDA Kernel3       Audio Buffer
     |                   |                   |                  |
GL Texture1         GL Texture2         GL Texture3        CPAL Output
     |                   |                   |                  |
  Present1           Present2            Present3           Speakers

接下来,你可能在想时间线或时间偏移的问题。但我们需要在帧级别上同步多路视频,所以思路是这样的:先把所有视频帧按组聚合(GROUP),然后再将视频组与音频 PCM 对齐。

分组本质上是一个数据收集工作:所有解码线程都必须提供一帧。如果某个线程速度不够快,分组线程就需要等待它。但是,这种等待不能阻塞解码过程本身,否则用户会感觉到卡顿。所以我们需要将解码和分组工作解耦——解码线程产生帧后,将帧发送给分组线程并缓存起来。分组线程则按照时间线模式循环,选取正确的帧并拷贝到 surface 缓冲区。

现在的设计应该是这样的:

                         Independent Decode Threads
================================================================================

Video 1            Video 2            Video 3                 Audio
--------           --------           --------                -----

Bitstream1         Bitstream2         Bitstream3            Bitstream4
     |                  |                  |                     |
HW Decoder1        HW Decoder2        HW Decoder3           Audio Decode
     |                  |                  |                     |
Frame Cache1       Frame Cache2       Frame Cache3          PCM Buffer
     |                  |                  |                     |
     +------------------+------------------+---------------------+
                        |                  |
                        | decoded frames   |  decoded pcm
                        |                  |
                        v                  v
                 +-------------------------------+
                 |        Grouping Thread        |
                 |-------------------------------|
                 | Wait until every cache has    |
                 | a frame for current timeline  |
                 | Align video frames with PCM   |
                 | Select Frame Group            |
                 +---------------+---------------+
                                 |
                  +--------------+--------------+
                  |              |              |
                  v              v              v
               Video 1        Video 2        Video 3
            Map Resource   Map Resource   Map Resource
                  |              |              |
            CUDA Kernel    CUDA Kernel    CUDA Kernel
                  |              |              |
             GL Texture     GL Texture     GL Texture
                  |              |              |
              Present1       Present2       Present3
                                ||
                                ||
                        CPAL Audio Output

由于我们用的是 NVDEC 做解码,速度非常快。如果没有阻塞机制,GPU 显存很快就会被耗尽并溢出。所以我们需要一个环形缓冲区(ringbuffer)设计来节制解码线程,必要时进行阻塞,同时持续向分组线程输送 GPU 帧数据。

我们可以使用两个 mpsc 通道:一个从解码线程接收数据并缓存,附带帧索引(用于分组)、PTS(用于音视频同步)、流哈希(用于定位)以及其他必要信息;然后发送到另一端——渲染侧——把帧卸载到 surface 上,再发回解码线程回收复用。

加入缓存机制后的版本:

                         Independent Decode Threads
================================================================================

Video 1            Video 2            Video 3                 Audio
--------           --------           --------                -----

Bitstream1         Bitstream2         Bitstream3            Bitstream4
     |                  |                  |                     |
HW Decoder1        HW Decoder2        HW Decoder3           Audio Decode
     |                  |                  |                     |
Frame Cache1       Frame Cache2       Frame Cache3          PCM Buffer
     |                  |                  |                     |
     +------------------+------------------+---------------------+
                        |                  ▲
                        | decoded frames   | recycled
                        v                  |
                +--------------------------------+
                |         Frame Cache            |
                |      (Ring Buffer Pool)        |
                +--------------------------------+
                        |                  ▲
                        | Load             | recycled
                        v                  |
                 +-------------------------------+
                 |        Grouping Thread        |
                 |-------------------------------|
                 | Wait until every cache has    |
                 | a frame for current timeline  |
                 | Align video frames with PCM   |
                 | Select Frame Group            |
                 +---------------+---------------+
                                 |
                  +--------------+--------------+
                  |              |              |
                  v              v              v
               Video 1        Video 2        Video 3
            Map Resource   Map Resource   Map Resource
                  |              |              |
            CUDA Kernel    CUDA Kernel    CUDA Kernel
                  |              |              |
             GL Texture     GL Texture     GL Texture
                  |              |              |
              Present1       Present2       Present3
                                ||
                                ||
                        CPAL Audio Output

最后,我们来考虑时间轴的工作逻辑。

分组线程处于一个无限循环中,它持续轮询环形缓冲池,如果有视频或音频数据到达,就把它们放入对应的视频/音频缓存中。每次循环都会计算 current pts(当前显示时间戳)和最新的视频/音频 PTS,如果视频/音频的 stream pts 晚于 current pts,就触发渲染。

CUDA 流水线的坑

CUDA API 是以流水线、异步的方式工作的。当你调用 cudaMemcpy 时,它实际上并不会阻塞——只是把工作排入队列然后立即返回。这对性能来说是好事,但在同步帧的时候可能会坑到你。如果你快速连续地把多帧拷贝到 surface,最终可能会得到错位的索引,因为实际的拷贝顺序并不严格遵循你的调用顺序。要解决这个问题,你需要显式地插入一个同步点,比如 cudaStreamSynchronize,确保所有操作完成后再继续。

OpenGL 的坑

OpenGL 有一个众所周知的限制:注册 GL 资源(比如纹理)的线程,必须和渲染该资源的线程是同一个。你不能把渲染任务交给另一个线程。所以在我们的设计中,注册和渲染都必须放在拥有 GL 上下文的那一层——在我们的场景里,就是 Unity3D。

总结

我们设计了一个多视频同步播放器,基于 OpenGL 构建,在某种程度上是跨操作系统的。如果在 Windows 上,我们也可以使用 D3D11 API——是的,CUDA 和 D3D11 的互操作 API 也是存在的。


Ext Link: https://shiqifeng2000.github.io/2026-07-08-shared-gpu-surface/

评论区

写评论

还没有评论

1 共 0 条评论, 1 页