
背景:我需要设计一个可以嵌入到 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。通过 cudaGraphicsGLRegisterImage 或 cudaGraphicsD3D11RegisterImage,我们可以将 OpenGL/D3D 的 surface 注册为 CUDA 资源。然后使用 cudaGraphicsMapResources 和 cudaGraphicsSubResourceGetMappedArray 获取资源缓冲区的指针或数组,这样 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
这个设计当然能工作——但我们还需要同步。
一个典型的视频播放器使用三种同步策略之一:
- 视频对齐到音频
- 音频对齐到视频
- 视频和音频都对齐到统一的时间线
我选择了第三种方案,因为我们的场景是多路视频流。每个视频文件包含相同的音频流,所以我选择第一个视频文件中的音频流,用 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/
评论区
写评论还没有评论