14、OpenGL ES视频渲染:YUV转RGB着色器、多纹理渲染、视频滤镜实现

视频渲染这块,说白了就是让手机屏幕把视频数据正确显示出来。但摄像头采集到的原始数据通常是YUV格式,而屏幕显示需要RGB。这个转换过程,就是咱们今天要啃的第一块硬骨头。

我记得刚入行那会儿,第一次用OpenGL ES渲染视频,画面全是绿的。排查了半天,发现是YUV到RGB的转换矩阵用错了。嗯,这种坑踩过一次,你就再也不会忘了。

14.1 YUV与RGB:视频渲染的基石

YUV和RGB是两种不同的色彩空间。RGB大家很熟悉,红绿蓝三通道。YUV呢,Y是亮度,UV是色度。为什么视频要用YUV?因为人眼对亮度敏感,对色度不敏感。压缩时可以多丢点UV信息,画质损失不大。

常见的YUV格式有:

格式 说明 常见场景
YUV420P (I420) 三个平面,Y、U、V分开存储 软件编码、FFmpeg解码输出
NV12 两个平面,Y平面 + UV交错平面 Android Camera、硬件编码器
NV21 两个平面,Y平面 + VU交错平面 旧版Android Camera

我个人习惯用NV12,因为Android硬件编码器默认输出就是它,省去一次格式转换。

14.2 YUV转RGB着色器:核心公式

YUV转RGB不是简单的加减法,它有一套标准转换公式。以BT.601标准为例:

// 顶点着色器(固定不变)
attribute vec4 aPosition;
attribute vec2 aTexCoord;
varying vec2 vTexCoord;
void main() {
    gl_Position = aPosition;
    vTexCoord = aTexCoord;
}

// 片元着色器(YUV420P转RGB)
precision mediump float;
varying vec2 vTexCoord;
uniform sampler2D yTexture;
uniform sampler2D uTexture;
uniform sampler2D vTexture;

void main() {
    float y = texture2D(yTexture, vTexCoord).r;
    float u = texture2D(uTexture, vTexCoord).r - 0.5;
    float v = texture2D(vTexture, vTexCoord).r - 0.5;

    vec3 rgb;
    rgb.r = y + 1.402 * v;
    rgb.g = y - 0.344 * u - 0.714 * v;
    rgb.b = y + 1.772 * u;

    gl_FragColor = vec4(rgb, 1.0);
}

这里有个细节:UV值要减去0.5,因为纹理采样返回的是[0,1]范围,而UV的合法范围是[-0.5, 0.5]。不做这步,颜色就全偏了。

注意:不同的色彩标准(BT.601、BT.709、BT.2020)转换矩阵不同。HDR视频用BT.2020,普通SDR用BT.601或BT.709。我曾经在项目中混用了矩阵,结果HDR视频颜色发灰,排查了两天才发现是标准没对齐。

14.3 多纹理渲染:三纹理 vs 双纹理

YUV420P需要三个纹理(Y、U、V各一个),NV12只需要两个纹理(Y一个,UV一个)。

多纹理渲染的关键点:

  • 纹理单元绑定:每个纹理要绑定到不同的纹理单元(GL_TEXTURE0、GL_TEXTURE1等)
  • 采样器对应:着色器中的uniform sampler2D要和纹理单元一一对应
  • 纹理参数:YUV纹理建议用GL_LINEAR过滤,不要用GL_NEAREST,否则会有锯齿
// 绑定纹理的代码片段
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, yTexId);
glUniform1i(yTextureLoc, 0);

glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, uTexId);
glUniform1i(uTextureLoc, 1);

glActiveTexture(GL_TEXTURE2);
glBindTexture(GL_TEXTURE_2D, vTexId);
glUniform1i(vTextureLoc, 2);

你想想看,如果纹理单元绑定错了,画面会怎样?要么全黑,要么颜色错乱。我调试时经常用glGetError()来排查这类问题。

14.4 视频滤镜实现:从简单到复杂

滤镜的本质,就是在片元着色器里对RGB值做数学变换。咱们从最简单的开始:

14.4.1 灰度滤镜

void main() {
    vec4 color = texture2D(inputTexture, vTexCoord);
    float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));
    gl_FragColor = vec4(vec3(gray), 1.0);
}

灰度滤镜的权重系数是有讲究的。人眼对绿色最敏感,所以绿色权重最高(0.587)。直接用平均值(0.333)的话,灰度图会偏暗。

14.4.2 冷暖色调滤镜

void main() {
    vec4 color = texture2D(inputTexture, vTexCoord);
    // 暖色调:增强红色,减弱蓝色
    color.r *= 1.2;
    color.b *= 0.8;
    gl_FragColor = color;
}

冷色调反过来,增强蓝色,减弱红色。这种滤镜实现简单,但效果明显。

14.4.3 查找表(LUT)滤镜

这是商业级App最常用的方案。把颜色映射关系存到一张小图里,渲染时查表替换。

// LUT滤镜着色器(简化版)
uniform sampler2D lutTexture; // 64x64的LUT图
void main() {
    vec4 color = texture2D(inputTexture, vTexCoord);
    // 根据RGB值在LUT图中查找映射后的颜色
    float blue = color.b * 63.0;
    vec2 quad1 = vec2(floor(blue) / 64.0, 0.0);
    vec2 quad2 = vec2(ceil(blue) / 64.0, 0.0);
    // ... 插值计算
    gl_FragColor = mappedColor;
}
我的经验:LUT滤镜的性能开销主要在纹理采样次数上。64x64的LUT图需要采样2-4次,然后做插值。如果追求极致性能,可以预计算成3D LUT,但移动端支持有限。

14.5 知识体系总览

下面这张图把本章的核心逻辑串起来了:

OpenGL ES视频渲染核心流程 视频帧数据 YUV420P / NV12 纹理上传 glTexImage2D 着色器处理 YUV→RGB + 滤镜 显示 滤镜类型 颜色变换滤镜 LUT查找表滤镜 卷积核滤镜 灰度 / 冷暖 / 怀旧 美颜 / 风格化 模糊 / 锐化 / 边缘检测 ⚠️ 常见坑点 • YUV转RGB矩阵用错 → 颜色偏色(BT.601 vs BT.709) • 纹理单元绑定顺序错误 → 画面错乱或全黑 • UV值未归一化到[-0.5, 0.5] → 颜色过曝或过暗

14.6 性能优化:别让渲染成为瓶颈

视频渲染对性能要求很高,尤其是高分辨率(4K)和高帧率(60fps)场景。我总结了几条优化经验:

  1. 减少纹理上传次数:用PBO(Pixel Buffer Object)异步上传,避免阻塞渲染线程
  2. 复用EGL上下文:不要每帧都创建销毁,用EGLSurface来回切换
  3. 着色器预编译:滤镜切换时,提前编译好所有着色器,运行时直接切换
  4. 降低纹理精度:如果不需要高精度,用GL_RGB565代替GL_RGBA8888,带宽减半

核心要点回顾:

  • YUV转RGB是视频渲染的第一步,矩阵选对是关键
  • 多纹理渲染要管理好纹理单元和采样器的对应关系
  • 滤镜本质是片元着色器里的数学变换,LUT是工业级方案
  • 性能优化从减少纹理上传和复用资源入手

好了,这一章的内容就到这儿。YUV转RGB的着色器代码我已经给了,建议你动手跑一遍。遇到颜色不对的情况,先检查矩阵,再检查纹理绑定——这两个地方出问题的概率最高。

公众号:蓝海资料掘金营,微信deep3321