移动端视频渲染与编码全链路优化:从 60s 到 2s 内零拷贝实战
在移动端视频编辑与特效相机类应用中,“特效实时渲染 + 离线合成导出”往往是整套系统中计算压力最大、耗时最长的核心链路。
在早期的视频导出管线中,由于历史原因采用了基于 FFmpeg 集成的 libx264 软编码器。在默认实现下,GPU 渲染完成的特效纹理必须经过多次跨总线拷贝与格式转换才能交给编码器,导致一段 20 秒、1308 × 1530 分辨率 的高帧率视频,导出总时长高达 1 分多钟(60s+),CPU 占用率长时间居高不下,严重影响导出体验与能耗表现。
本文将完整梳理这段优化旅程,从最基础的编码参数调优、GPU 着色器格式转换,到移动端 PBO 异步回读的踩坑与理论反思,再到利用 Apple 原生 CVPixelBuffer 共享内存以及最终打通 VideoToolbox 硬件编码实现真正的全链路零拷贝。
优化演进全景
整个优化过程经历了一系列迭代,每一步都在解决前一阶段暴露出的最大瓶颈:
| 优化阶段 | 核心技术手段 | 20s 视频耗时 | 阶段收益与发现的瓶颈 |
|---|---|---|---|
| 0.0 初始基线 | x264 默认参数 + CPU libswscale + glReadPixels | 60s+ | 耗时极长,CPU 严重满载 |
| 1.0 软编调优 | x264 参数调优(preset / tune / profile) | ~20s | 编码速度提升 3 倍,但色彩转换与回读成瓶颈 |
| 2.0 GPU 转换 | GLSL 片元着色器完成 RGBA -> I420 | ~15s | 彻底卸载 CPU 转换算力,但 glReadPixels 阻塞显著 |
| 3.0 PBO 探索 | 尝试双 PBO(Ping-Pong Buffer)异步回读 | ~22s (负优化) | 移动端 TBDR 架构触发强制 Flush,性能反向恶化 |
| 4.0 显存映射 | CVOpenGLESTextureCache 跨 API 共享显存 | ~12s | 消除 glReadPixels CPU-GPU 跨总线拷贝 |
| 5.0 格式升级 | 使用 Semi-Planar(NV21/NV12)代替 I420 | ~9s | Shader 采样减半,规避 16 字节对齐的行填充计算 |
| 6.0 终极方案 | VideoToolbox 硬件编码 + 双平面 CVPixelBuffer | 不足 2s | 彻底绕开 CPU 软编与内存拷贝,实现真正的零拷贝 |
1.0 修改 x264 编码参数,快速止血
在最初的实现中,视频帧经过特效处理后输出 RGBA 数据,转换为 YUV420P 后交给 libx264 进行软编码。由于代码中未配置任何编码预设,x264 采用了默认的 preset = medium。
对于桌面端视频压制,medium 可以在压缩率与编码耗时之间取得良好平衡;但在移动端设备上,其深度运动搜索、多参考帧决策和复杂的宏块划分会给移动 CPU 带来灾难性的计算负担。
通过调整 FFmpeg 的编码私有选项,降低压缩率以换取吞吐速度:
// 1. 查找 x264 编码器
*codec = avcodec_find_encoder_by_name("libx264");
// 2. 设置 x264 编码预设,大幅削减运动搜索复杂度与帧间预测开销
// 可选 preset: ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow, placebo
av_opt_set(codec_ctx->priv_data, "preset", "ultrafast", 0);
// 3. 设置 tune 为零延迟,关闭 B 帧并禁用帧重排缓存
// 可选 tune: film, animation, grain, stillimage, psnr, ssim, fastdecode, zerolatency
av_opt_set(codec_ctx->priv_data, "tune", "zerolatency", 0);
// 4. 限制 Profile 级别
// 可选 profile: baseline, main, high, high10, high422, high444
av_opt_set(codec_ctx->priv_data, "profile", "main", 0);
调优效果:
preset = ultrafast显著简化了运动搜索算法与亚像素精度估计;tune = zerolatency关闭了 Lookahead 缓冲,编码器输入一帧立即输出一包 NALU;- 耗时直接从 60s+ 下降到约 20s。
2.0 GPU 硬件加速色彩空间转换(RGBA -> I420)
编码参数调整后,20s 视频耗时稳定在 20s 左右(刚好接近 1:1 的实时比),但分析性能调用栈发现,CPU 依然背负着沉重的负担。
原因在于:视频渲染管线在 OpenGL 中输出的是 RGBA 格式纹理,而 x264 编码器需要原始的 YUV(如 yuv420p)数据作为输入:
let videoInfo = VideoInfo()
videoInfo.size = CGSize(width: 1308, height: 1530)
videoInfo.fps = demuxer.videoInfo.fps
videoInfo.pixelFmt = AV_PIX_FMT_BGRA.rawValue
最初通过 CPU 侧读取 RGBA 内存后,调用 FFmpeg 的 libswscale 进行色彩转换:
// 像素格式转换:将 RGBA 转换为 YUV420p,采用 SWS_BILINEAR 双线性采样
ost->m_sws_ctx = sws_getContext(c->width, c->height, AV_PIX_FMT_BGRA,
c->width, c->height, AV_PIX_FMT_YUV420p,
SWS_BILINEAR, NULL, NULL, NULL);
if (!ost->m_sws_ctx) {
std::cout << "Could not initialize the conversion context" << std::endl;
return NULL;
}
// 纯 CPU 进行图像重采样与色彩空间计算
sws_scale(ost->m_sws_ctx, (const uint8_t * const *) ost->m_frame->data,
ost->m_frame->linesize, 0, c->height, ost->m_tmp_frame->data,
ost->m_tmp_frame->linesize);
sws_scale 在移动端 CPU 上的纯软件矩阵运算极度消耗时钟周期。既然源数据本就在 GPU 显存内,完全可以利用 GPU 的高并发着色器直接完成色彩空间转换。
GLSL 着色器实现 RGBA 到 I420 的转换
I420 包含三个独立平面:Y 平面占全分辨率高 $H$,U 与 V 平面在宽高方向均降采样至 $1/2$,分别占高 $H/4$。
我们通过调整输出 FBO 使其总字节容量等于 $W \times H \times 1.5$,在单一 Pass 内通过片元着色器划分三个采样区间:
- 坐标区间
[0, 2/3]:写入 Y 平面; - 坐标区间
(2/3, 5/6]:写入 U 平面; - 坐标区间
(5/6, 1.0]:写入 V 平面。
#version 300 es
precision mediump float;
layout(location = 0) out vec4 outColor;
in vec2 v_texCoord;
uniform sampler2D inputImageTexture;
uniform vec2 u_ImgSize; // 图像真实尺寸
const vec3 COEF_Y = vec3( 0.299, 0.587, 0.114);
const vec3 COEF_U = vec3(-0.147, -0.289, 0.436);
const vec3 COEF_V = vec3( 0.615, -0.515, -0.100);
const float U_DIVIDE_LINE = 2.0 / 3.0;
const float V_DIVIDE_LINE = 5.0 / 6.0;
void main() {
float offsetX = 1.0 / u_ImgSize.x;
vec2 texelOffset = vec2(offsetX, 0.0);
if (v_texCoord.y <= U_DIVIDE_LINE) {
// [Y 平面] 每个像素都需采样
// 一次采样(加三次横向偏移)4 个 RGBA 像素,打包进 RGBA 四个分量
vec2 texCoord = vec2(v_texCoord.x, v_texCoord.y * 3.0 / 2.0);
vec4 color0 = texture(inputImageTexture, texCoord);
vec4 color1 = texture(inputImageTexture, texCoord + texelOffset);
vec4 color2 = texture(inputImageTexture, texCoord + texelOffset * 2.0);
vec4 color3 = texture(inputImageTexture, texCoord + texelOffset * 3.0);
float y0 = dot(color0.rgb, COEF_Y);
float y1 = dot(color1.rgb, COEF_Y);
float y2 = dot(color2.rgb, COEF_Y);
float y3 = dot(color3.rgb, COEF_Y);
outColor = vec4(y0, y1, y2, y3);
} else if (v_texCoord.y <= V_DIVIDE_LINE) {
// [U 平面] 垂直与水平方向均隔行隔列降采样
float offsetY = 1.0 / 3.0 / u_ImgSize.y;
vec2 texCoord;
if (v_texCoord.x <= 0.5) {
texCoord = vec2(v_texCoord.x * 2.0, (v_texCoord.y - U_DIVIDE_LINE) * 2.0 * 3.0);
} else {
texCoord = vec2((v_texCoord.x - 0.5) * 2.0, ((v_texCoord.y - U_DIVIDE_LINE) * 2.0 + offsetY) * 3.0);
}
vec4 color0 = texture(inputImageTexture, texCoord);
vec4 color1 = texture(inputImageTexture, texCoord + texelOffset * 2.0);
vec4 color2 = texture(inputImageTexture, texCoord + texelOffset * 4.0);
vec4 color3 = texture(inputImageTexture, texCoord + texelOffset * 6.0);
float u0 = dot(color0.rgb, COEF_U) + 0.5;
float u1 = dot(color1.rgb, COEF_U) + 0.5;
float u2 = dot(color2.rgb, COEF_U) + 0.5;
float u3 = dot(color3.rgb, COEF_U) + 0.5;
outColor = vec4(u0, u1, u2, u3);
} else {
// [V 平面] 采样逻辑与 U 平面类似
float offsetY = 1.0 / 3.0 / u_ImgSize.y;
vec2 texCoord;
if (v_texCoord.x <= 0.5) {
texCoord = vec2(v_texCoord.x * 2.0, (v_texCoord.y - V_DIVIDE_LINE) * 2.0 * 3.0);
} else {
texCoord = vec2((v_texCoord.x - 0.5) * 2.0, ((v_texCoord.y - V_DIVIDE_LINE) * 2.0 + offsetY) * 3.0);
}
vec4 color0 = texture(inputImageTexture, texCoord);
vec4 color1 = texture(inputImageTexture, texCoord + texelOffset * 2.0);
vec4 color2 = texture(inputImageTexture, texCoord + texelOffset * 4.0);
vec4 color3 = texture(inputImageTexture, texCoord + texelOffset * 6.0);
float v0 = dot(color0.rgb, COEF_V) + 0.5;
float v1 = dot(color1.rgb, COEF_V) + 0.5;
float v2 = dot(color2.rgb, COEF_V) + 0.5;
float v3 = dot(color3.rgb, COEF_V) + 0.5;
outColor = vec4(v0, v1, v2, v3);
}
}
渲染完成后,CPU 只需分配一块内存读取显存,然后直接拆解分量填充 AVFrame:
// 为 YUV420p 分配整块内存 (宽 * 高 * 1.5)
imgByteSize = Int(bufferSize.width * bufferSize.height * 3 / 2)
let address = malloc(imgByteSize)
pixelBuffer = unsafeBitCast(address, to: UnsafeMutablePointer<UInt8>.self)
// 同步回读纹理数据
glReadPixels(0, 0, renderFramebuffer.size.width, renderFramebuffer.size.height,
GLenum(GL_RGBA), GLenum(GL_UNSIGNED_BYTE), pixelBuffer)
将数据分片赋值给 AVFrame:
- (void)writeVideoData:(uint8_t *)data {
AVFrame *frame = _muxer->get_video_buffer();
int ySize = _videoInfo.size.width * _videoInfo.size.height;
int uSize = ySize / 4;
memcpy(frame->data[0], data, ySize); // Y
memcpy(frame->data[1], data + ySize, uSize); // U
memcpy(frame->data[2], data + ySize + uSize, uSize); // V
_muxer->write_video_frame(frame);
}
调优效果:
彻底卸载了 CPU 侧的 sws_scale 格式转换开销,总体耗时由 20s 降至 15s。
3.0 深入探索:移动端尝试双 PBO 异步回读为何“翻车”?
在阶段 2 之后,通过精确的时间埋点发现了新的卡顿瓶颈:glReadPixels() 是一个同步阻塞操作,它强迫 CPU 暂停执行,直到 GPU 将所有排队绘制指令清空并将像素同步至 CPU 缓冲区。
实测埋点数据如下,单帧阻塞高达 15ms ~ 24ms:
glReadPixels 耗时: 24.58 ms
glReadPixels 耗时: 16.06 ms
glReadPixels 耗时: 14.82 ms
glReadPixels 耗时: 14.55 ms
glReadPixels 耗时: 15.38 ms
glReadPixels 耗时: 16.02 ms
尝试方案:双 PBO 乒乓缓冲(Ping-Pong Buffer)
在桌面 OpenGL 开发中,经典的无阻塞回读方案是使用 PBO(Pixel Buffer Object):
- 分配两个 PBO 缓冲区(
PBO[0]和PBO[1]); - 这一帧使用
glReadPixels向PBO[index]发起异步传输(理论上立即返回); - CPU 通过
glMapBufferRange读取上一帧已经就绪的PBO[nextIndex]。
// 初始化双 PBO 缓冲区
glGenBuffers(2, &downloadPboId[0])
glBindBuffer(GLenum(GL_PIXEL_PACK_BUFFER), downloadPboId[0])
glBufferData(GLenum(GL_PIXEL_PACK_BUFFER), imgByteSize, nil, GLenum(GL_STREAM_READ))
glBindBuffer(GLenum(GL_PIXEL_PACK_BUFFER), downloadPboId[1])
glBufferData(GLenum(GL_PIXEL_PACK_BUFFER), imgByteSize, nil, GLenum(GL_STREAM_READ))
glBindBuffer(GLenum(GL_PIXEL_PACK_BUFFER), 0)
// 帧循环中交替处理
let index = frameIndex % 2
let nextIndex = (index + 1) % 2
frameIndex += 1
// 通知 GPU 将当前帧纹理异步传输至 downloadPboId[index]
glBindBuffer(GLenum(GL_PIXEL_PACK_BUFFER), downloadPboId[index])
glReadPixels(0, 0, yuvBufferSize.width, yuvBufferSize.height, GLenum(GL_RGBA), GLenum(GL_UNSIGNED_BYTE), nil)
// CPU 映射并读取上一帧 downloadPboId[nextIndex] 的数据
glBindBuffer(GLenum(GL_PIXEL_PACK_BUFFER), downloadPboId[nextIndex])
let bufPtr = glMapBufferRange(GLenum(GL_PIXEL_PACK_BUFFER), 0, imgByteSize, GLenum(GL_MAP_READ_BIT))
if bufPtr != nil {
pixelBuffer = unsafeBitCast(bufPtr, to: UnsafeMutablePointer<UInt8>.self)
glUnmapBuffer(GLenum(GL_PIXEL_PACK_BUFFER))
}
glBindBuffer(GLenum(GL_PIXEL_PACK_BUFFER), 0)
实际测试结果:性能出现负优化
实际运行后,控制台输出的耗时令人大跌眼镜:
glReadPixels 耗时: 36.96 ms | glMapBufferRange 耗时: 0.06 ms
glReadPixels 耗时: 36.18 ms | glMapBufferRange 耗时: 0.06 ms
glReadPixels 耗时: 36.53 ms | glMapBufferRange 耗时: 0.06 ms
glReadPixels 耗时: 38.46 ms | glMapBufferRange 耗时: 0.06 ms
glReadPixels 耗时: 36.79 ms | glMapBufferRange 耗时: 0.06 ms
- 虽然
glMapBufferRange只耗费 0.06ms,但glReadPixels的耗时从原先的 15ms 暴涨至 36ms+; - 总体导出耗时不仅没有下降,反而从 15s 倒退到了 22s!
踩坑复盘:为什么移动端 PBO 会出现反效果?
这反映了桌面独立显卡与移动端集成架构的核心差异:
- 移动端 TBDR(Tile-Based Deferred Rendering)架构: iOS 平台的 PowerVR 和 Apple 自研 GPU 使用分块延时渲染机制。GPU 不会在收到 DrawCall 时立即绘制像素,而是将图元打包进 Tile 列表中,待整帧渲染指令完成后集中光栅化。
- 强制管线 Flush:
调用
glReadPixels绑定 PBO 期望发起异步 DMA 传输时,由于驱动为了维护数据一致性,必须强制打断延迟执行流水线,提前将所有 Tile 数据 Flush 刷回系统主存。 - UMA(Unified Memory Architecture)统一内存: 桌面端由于 PCIe 总线带宽受限,显存(VRAM)与系统内存(RAM)物理隔离,PBO 能够利用专有 DMA 控制器异步搬运;但在 iOS 设备上,CPU 与 GPU 物理共享同一块 LPDDR 内存,并不存在真正的物理总线跨越。驱动在维护 PBO 状态机与内存屏障时引入了大量额外的同步开销。
经验教训:在 iOS 平台上,不要盲目套用桌面 OpenGL 的 PBO 优化范式,应当使用系统原生的跨框架共享机制。
4.0 使用 CVOpenGLESTexture 直接打通 GPU 显存
回顾之前的方案,数据链路依然存在两次内存拷贝:
- GPU 显存 ->
malloc出来的 CPU 内存(通过阻塞式的glReadPixels); - CPU 内存 ->
AVFrame结构体(通过memcpy)。
能否让 CVPixelBuffer 与 OpenGL 纹理直接映射到同一块共享内存?
答案是利用 CoreVideo 框架的 CVOpenGLESTextureCacheCreateTextureFromImage。通过该 API,我们可以从 CVPixelBuffer 直接生成一个 OpenGL 纹理,让二者背后的底层物理内存完全同源。
核心实现
// 1. 创建 CVPixelBufferPool 进行内存复用
var attributes = [String: Any]()
attributes[kCVPixelBufferPixelFormatTypeKey as String] = kCVPixelFormatType_32BGRA
attributes[kCVPixelBufferWidthKey as String] = yuvBufferSize.width
attributes[kCVPixelBufferHeightKey as String] = yuvBufferSize.height
attributes[kCVPixelBufferIOSurfacePropertiesKey as String] = [:] // 启用 IOSurface 共享
CVPixelBufferPoolCreate(kCFAllocatorDefault, nil, attributes as CFDictionary, &self.pixelBufferPool)
guard let pixelBufferPool = pixelBufferPool else { return }
// 2. 从池中取出 CVPixelBuffer
CVPixelBufferPoolCreatePixelBuffer(nil, pixelBufferPool, &self.pixelBuffer)
CVBufferSetAttachment(self.pixelBuffer!, kCVImageBufferColorPrimariesKey, kCVImageBufferColorPrimaries_ITU_R_709_2, .shouldPropagate)
CVBufferSetAttachment(self.pixelBuffer!, kCVImageBufferYCbCrMatrixKey, kCVImageBufferYCbCrMatrix_ITU_R_601_4, .shouldPropagate)
CVBufferSetAttachment(self.pixelBuffer!, kCVImageBufferTransferFunctionKey, kCVImageBufferTransferFunction_ITU_R_709_2, .shouldPropagate)
// 3. 从 CVPixelBuffer 创建纹理,此时两者共享同一片物理内存
var cachedTextureRef: CVOpenGLESTexture? = nil
CVOpenGLESTextureCacheCreateTextureFromImage(
kCFAllocatorDefault,
sharedImageProcessingContext.coreVideoTextureCache,
self.pixelBuffer!,
nil,
GLenum(GL_TEXTURE_2D),
GL_RGBA,
GLsizei(yuvBufferSize.width),
GLsizei(yuvBufferSize.height),
GLenum(GL_BGRA),
GLenum(GL_UNSIGNED_BYTE),
0,
&cachedTextureRef
)
let cachedTexture = CVOpenGLESTextureGetName(cachedTextureRef!)
self.renderFramebuffer = try? Framebuffer(
context: sharedImageProcessingContext,
orientation: .portrait,
size: yuvBufferSize,
textureOnly: false,
overriddenTexture: cachedTexture
)
渲染完成后,无需调用 glReadPixels,只需同步等待 GPU 指令执行:
// 锁定 CVPixelBuffer 基地址
CVPixelBufferLockBaseAddress(pixelBuffer!, CVPixelBufferLockFlags(rawValue: 0))
// 驱动 GPU 完成渲染管线
glFinish()
// 读取像素数据后解锁
CVPixelBufferUnlockBaseAddress(pixelBuffer!, CVPixelBufferLockFlags(rawValue: 0))
此时从 GPU 到 CPU 的同步拷贝被彻底消除,CPU 仅需执行一次向 AVFrame 的数据转移。
调优效果: 导出耗时从 15s 降至约 12s。
5.0 使用 NV21 / NV12 代替 I420:攻克内存对齐难题
在阶段 4 的基础上,我们在向编码器传递数据时遇到了一个非常隐蔽却棘手的问题:行字节对齐(Stride / Linesize Padding)。
I420 在对齐处理上的天然缺陷
CVPixelBuffer 在分配内存时,硬件驱动为了优化内存访存速度,强制要求每行像素按照 16 像素(或 64 字节)对齐。
例如一张真实宽度为 327 的图像,经过 16 字节对齐后,每行的实际内存跨度变成了 336(填充了 9 个字节的无用数据):
真实数据 (327 bytes) 填充字节 Padding (9 bytes)
[===========================][*********] -> 336 bytes (BytesPerRow)
在 I420 格式下:
- Y、U、V 是三个完全独立的平面;
- U 和 V 平面的宽度均为原图的一半,导致 U 和 V 各自的行对齐填充量难以统一预测;
- 如果要将紧密排列的 Y、U、V 分离,CPU 必须用循环进行 逐行解析与拷贝,这抵消了很大一部分优化红利。
NV21 / NV12 的架构优势
NV21 与 NV12 属于 Semi-Planar 格式:
- Y 分量单独存储为一个平面;
- UV 分量交织存放在另一个平面(NV21 为 VUVU...,NV12 为 UVUV...)。
以 4 x 4 像素为例:
NV21 排布 (Y 独立, VU 交织) NV12 排布 (Y 独立, UV 交织)
Y Y Y Y Y Y Y Y
Y Y Y Y Y Y Y Y
Y Y Y Y Y Y Y Y
Y Y Y Y Y Y Y Y
V U V U U V U V
V U V U U V U V
这种排布带来了两个巨大好处:
- 着色器采样开销更小: 原先 I420 需要在着色器中对 Y、U、V 做 3 次条件分支与采样;而 NV21 仅需分为“Y 区间”与“VU 区间”两次采样。
- 巧妙化解行对齐:
VU 作为一个整体平面,其宽度为原图的一半,但每个采样单元包含 V 和 U 两个字节,因此 VU 平面的行字节数(BytesPerRow)与 Y 平面的行字节数完全一致:
BytesPerRow_VU = (W / 2) * 2 = W = BytesPerRow_Y这意味着无论是 Y 还是 VU 平面,行对齐填充量完全相同!我们无需在 CPU 逐行剥离填充,直接将linesize[0]和linesize[1]赋给AVFrame,编码器底层便能根据行步长自动跳过 Padding。
NV21 片元着色器实现
#version 300 es
precision mediump float;
layout(location = 0) out vec4 outColor;
in vec2 v_texCoord;
uniform sampler2D inputImageTexture;
uniform vec2 u_ImgSize;
const vec3 COEF_Y = vec3( 0.299, 0.587, 0.114);
const vec3 COEF_U = vec3(-0.147, -0.289, 0.436);
const vec3 COEF_V = vec3( 0.615, -0.515, -0.100);
const float UV_DIVIDE_LINE = 2.0 / 3.0;
void main() {
float offsetX = 1.0 / u_ImgSize.x;
vec2 texelOffset = vec2(offsetX, 0.0);
if (v_texCoord.y <= UV_DIVIDE_LINE) {
// [Y 平面采样]
vec2 texCoord = vec2(v_texCoord.x, v_texCoord.y * 3.0 / 2.0);
vec4 color0 = texture(inputImageTexture, texCoord);
vec4 color1 = texture(inputImageTexture, texCoord + texelOffset);
vec4 color2 = texture(inputImageTexture, texCoord + texelOffset * 2.0);
vec4 color3 = texture(inputImageTexture, texCoord + texelOffset * 3.0);
float y0 = dot(color0.rgb, COEF_Y);
float y1 = dot(color1.rgb, COEF_Y);
float y2 = dot(color2.rgb, COEF_Y);
float y3 = dot(color3.rgb, COEF_Y);
outColor = vec4(y0, y1, y2, y3);
} else {
// [VU 平面采样] 一次采样 4 个 RGBA 像素生成一组 (V0, U0, V1, U1)
vec2 texCoord = vec2(v_texCoord.x, (v_texCoord.y - UV_DIVIDE_LINE) * 3.0);
vec4 color0 = texture(inputImageTexture, texCoord);
vec4 color1 = texture(inputImageTexture, texCoord + texelOffset);
vec4 color2 = texture(inputImageTexture, texCoord + texelOffset * 2.0);
vec4 color3 = texture(inputImageTexture, texCoord + texelOffset * 3.0);
float v0 = dot(color0.rgb, COEF_V) + 0.5;
float u0 = dot(color1.rgb, COEF_U) + 0.5;
float v1 = dot(color2.rgb, COEF_V) + 0.5;
float u1 = dot(color3.rgb, COEF_U) + 0.5;
outColor = vec4(v0, u0, v1, u1);
}
}
CPU 端仅需两次整块内存拷贝,即可直接投递给编码器:
- (void)writeVideoData2:(CVPixelBufferRef)pixelBuffer {
AVFrame *frame = _muxer->get_video_buffer();
CVPixelBufferLockBaseAddress(pixelBuffer, 0);
uint8_t *baseAddress = (uint8_t *)CVPixelBufferGetBaseAddress(pixelBuffer);
// Y 平面整块拷贝
int ySize = frame->linesize[0] * frame->height;
memcpy(frame->data[0], baseAddress, ySize);
// UV 平面整块拷贝 (得益于对齐一致性,无需逐行剥离)
int uSize = frame->linesize[1] * frame->height / 2.0;
memcpy(frame->data[1], baseAddress + ySize, uSize);
CVPixelBufferUnlockBaseAddress(pixelBuffer, 0);
_muxer->write_video_frame(frame);
}
调优效果: 耗时进一步从 12s 压缩至 9s。
6.0 终极方案:VideoToolbox 硬件编码与真正的零拷贝直传
走到第 5 步,性能已经从最初的 60s+ 提升到了 9s,但我们提出了更深层的问题:
既然我们已经拿到了由 GPU 渲染出的
CVPixelBuffer,为什么还要让 CPU 做一次memcpy塞给AVFrame,再用 CPU 进行 x264 软件编码?为什么不直接利用 Apple 芯片原生的专用硬件编码模块?
避坑:多渲染目标(MRT)的尺寸陷阱
为了直接生成 iOS 硬件编码器原生支持的 BiPlanar 格式(即 kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange,简称 420v),我们最初尝试在 OpenGL ES 3.0 中使用 MRT(多渲染目标),期望在一个 Pass 内将纹理 0(全分辨率存 Y)和纹理 1(半分辨率存 UV)同时绑定输出。
踩坑现象: 在 OpenGL ES 3.0 规范中,如果一个 Framebuffer 绑定的多个颜色附件(Color Attachments)尺寸不一致,视口区域会被钳制在最小纹理的范围上。导致全尺寸的 Y 纹理实际上只绘制了左下角 $1/4$ 的区域,其余区域全为黑色无效数据。
正确解法:面向 BiPlanar CVPixelBuffer 的分通道渲染
利用 CoreVideo 支持的双平面 CVPixelBuffer(Plane 0 存 Y,Plane 1 存 UV),分别为两个 Plane 生成对应的纹理对象,通过 Shader 精准按需渲染:
#version 300 es
precision mediump float;
layout (location = 0) out vec4 outColor;
in vec2 v_texCoord;
uniform sampler2D inputImageTexture;
uniform vec2 u_imgSize; // 图像真实尺寸
uniform bool u_isYPlane; // 用于区分 Y 平面与 UV 平面
const vec3 COEF_Y = vec3( 0.299, 0.587, 0.114);
const vec3 COEF_U = vec3(-0.169, -0.331, 0.500);
const vec3 COEF_V = vec3( 0.500, -0.439, -0.081);
void main() {
// 渲染 Y 平面 (Plane 0)
if (u_isYPlane) {
vec4 color0 = texture(inputImageTexture, v_texCoord);
float y = dot(color0.rgb, COEF_Y);
outColor = vec4(y, 0.0, 0.0, 1.0);
}
// 渲染 UV 平面 (Plane 1)
if (!u_isYPlane && v_texCoord.x <= 0.5 && v_texCoord.y <= 0.5) {
vec2 texOffset = vec2(1.0 / u_imgSize.x, 0.0);
vec2 texCoord = vec2(v_texCoord.x * 2.0, v_texCoord.y * 2.0);
vec4 color00 = texture(inputImageTexture, texCoord);
vec4 color1 = texture(inputImageTexture, texCoord + texOffset);
float v = 0.5 + dot(color00.rgb, COEF_V);
float u = 0.5 + dot(color1.rgb, COEF_U);
outColor = vec4(u, v, 0.0, 1.0);
}
}
跨越 FFmpeg 与 VideoToolbox:指针直传零拷贝
当我们将编码器切换为 h264_videotoolbox 时,FFmpeg 针对 Apple 平台提供了硬件直通支持。此时 AVFrame 不需要分配任何内存,只需按照硬件加速约定,将 CVPixelBufferRef 强转为指针挂载在 frame->data[3] 上:
/// 写入 VideoToolbox 默认像素格式 (420v) - 硬件编码极致零拷贝
/// @param pixelBuffer Plane 0 存储 Y 数据,Plane 1 存储 UV 数据
- (void)writeVideoToolBoxPixelData:(CVPixelBufferRef)pixelBuffer {
AVFrame *frame = _muxer->get_video_buffer();
// 将 iOS 原生 CVPixelBufferRef 直接赋给 data[3] 句柄
frame->data[3] = (uint8_t *)pixelBuffer;
// 提交硬编码器,底层由 Apple 专用编解码硬件直接读取
_muxer->write_video_frame(frame);
}
终极数据链路
[GPU 特效渲染完成]
↓ (GPU 内部直写,无总线跨越)
[CoreVideo BiPlanar CVPixelBuffer (420v)]
↓ (跨模块传递指针句柄,0 次 CPU 拷贝)
[Apple VideoToolbox 硬件编码单元 (ASIC)]
↓ (硬件编码完成)
[H.264 码流 -> MP4 容器封装]
调优效果:
- CPU 参与度近乎为 0:CPU 不再参与任何格式转换、行跨度对齐或内存搬运;
- 耗时从 9s 进一步压至 2 秒以内,完全摆脱了 CPU 性能限制与发热降频隐患。
优化历程数据总结
处理 20s、1308 × 1530 分辨率的高清视频,各项指标的演进对比如下:
| 评估维度 | 初始基线方案 | 中期优化方案(阶段 5) | 终极硬件零拷贝方案(阶段 6) |
|---|---|---|---|
| 总导出耗时 | 60s+ | 9s | 不足 2s |
| 相对初始提速 | 1.0x (基准) | 6.7x | 30x+ |
| CPU 占用率 | 180% ~ 200% (打满) | 120% ~ 150% | 低于 15% (主要等待 I/O) |
| 内存拷贝次数 | 3 次(GPU -> CPU -> swscale -> AVFrame) | 1 次(显存映射 -> AVFrame) | 0 次(全链路共享句柄直传) |
| 格式与对齐处理 | CPU 双重循环逐行计算 | 利用 linesize 统一吸收 | 驱动与硬件 ASIC 原生处理 |
| 设备能耗与发热 | 极高,连续导出严重降频 | 中等 | 几乎无发热 |
移动端音视频优化的核心心法
- 破除桌面思维定势,深刻理解移动端芯片架构: 在桌面独立显卡上被广泛采用的技术(如双 PBO 异步回读),在移动端统一内存架构(UMA)与 TBDR 驱动机制下可能会引发管线强制 Flush,甚至造成严重的性能倒退。
- 移动端性能优化的头号敌人是“跨总线数据搬运”: 在音视频处理中,纯粹的矩阵运算对于现代 GPU 几乎是轻而易举的,真正的性能开销永远藏在跨 API、跨总线的数据拷贝之中。尽可能让数据留在 GPU 或共享显存中。
- 拥抱平台原生的基础设施:
在 Apple 平台体系下,
CVPixelBuffer+IOSurface+CVOpenGLESTextureCache/Metal是连接渲染、视觉算法(CoreImage/Vision)与硬编解码(VideoToolbox)的通用桥梁。采用平台最原生的像素排布(如420v/NV12),能够自然享受系统的零拷贝通道。 - 警惕内存对齐(Stride)带来的隐性内耗: 移动硬件普遍存在 16 字节或 64 字节对齐规则。在设计 Shader 输出布局时,优先选择 Semi-Planar(NV12/NV21)结构,能让色度平面的行跨度与亮度平面完美保持一致,消除 CPU 手动剥离 Padding 的算力浪费。