23、OpenGL ES后处理:YUV转RGB着色器、色彩增强、滤镜效果、GPU加速

各位同学,今天我们来聊聊视频硬解码后的重头戏——OpenGL ES后处理

解码器吐出来的数据,通常是YUV格式。但屏幕要的是RGB。这个转换,如果放在CPU上做,性能会很难看。我早期做播放器时,就踩过这个坑——解码帧率明明有60fps,一进渲染管线直接掉到30fps。后来才发现,瓶颈就在YUV转RGB这一步。

所以,用GPU做后处理,是Android视频开发的必修课。今天我会把YUV转RGB的着色器写法、色彩增强、滤镜效果,以及GPU加速的实战技巧,一次性讲透。

23.1 YUV转RGB着色器:从数学到代码

YUV转RGB,说白了就是一个矩阵乘法。不同的颜色标准(BT.601、BT.709、BT.2020),系数不一样。我习惯在着色器里写死BT.709的系数,因为大部分高清视频都用这个标准。

先看顶点着色器,它负责传递纹理坐标:

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

片元着色器才是核心。这里要注意:YUV数据通常分三个纹理传入(Y、U、V各一个)。采样后,用矩阵运算转成RGB:

// 片元着色器 - YUV转RGB (BT.709)
precision mediump float;
uniform sampler2D uTextureY;
uniform sampler2D uTextureU;
uniform sampler2D uTextureV;
varying vec2 vTexCoord;

void main() {
    float y = texture2D(uTextureY, vTexCoord).r;
    float u = texture2D(uTextureU, vTexCoord).r - 0.5;
    float v = texture2D(uTextureV, vTexCoord).r - 0.5;

    vec3 rgb;
    rgb.r = y + 1.5748 * v;
    rgb.g = y - 0.1873 * u - 0.4681 * v;
    rgb.b = y + 1.8556 * u;

    gl_FragColor = vec4(rgb, 1.0);
}
我的经验:U和V分量一定要先减去0.5,把范围从[0,1]映射到[-0.5,0.5]。否则颜色会整体偏绿。我曾经因为这个bug排查了整整一个下午。

23.2 色彩增强:让画面更通透

YUV转RGB只是基础。很多时候,解码出来的画面偏灰、偏暗。这时候就需要色彩增强

我个人常用的手法是对比度+饱和度联合调整。在RGB空间里,先转成HSL或HSV,调整后再转回来。但这样计算量太大。我推荐一个轻量级方案——直接在RGB空间做线性变换:

// 色彩增强着色器
uniform float uContrast;   // 对比度,1.0为原始
uniform float uSaturation; // 饱和度,1.0为原始

void main() {
    vec4 color = texture2D(uTexture, vTexCoord);

    // 对比度调整
    color.rgb = (color.rgb - 0.5) * uContrast + 0.5;

    // 饱和度调整
    float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));
    color.rgb = mix(vec3(gray), color.rgb, uSaturation);

    gl_FragColor = color;
}

你想想看,uContrast设为1.2,画面立刻精神很多。uSaturation设为1.3,色彩就鲜艳了。这两个参数配合使用,能覆盖大部分调色需求。

注意:对比度不要超过1.5,否则高光区域会严重过曝。我见过有人为了“通透”把对比度拉到2.0,结果人脸直接变成“阴阳脸”。

23.3 滤镜效果:从LUT到实时算法

滤镜,说白了就是给画面套一层“风格”。实现方式有两种:LUT(颜色查找表)实时算法

LUT方案:预先生成一个3D颜色映射表(通常是64x64x64),在着色器里根据输入颜色查表输出。优点是效果丰富,缺点是LUT文件大、加载慢。

实时算法方案:直接在着色器里写数学公式。比如复古滤镜,就是降低饱和度、增加暖色调:

// 复古滤镜
void main() {
    vec4 color = texture2D(uTexture, vTexCoord);

    // 降低饱和度
    float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));
    color.rgb = mix(color.rgb, vec3(gray), 0.4);

    // 增加暖色调
    color.r *= 1.1;
    color.g *= 0.95;

    gl_FragColor = color;
}

我项目中更倾向于混合方案:基础滤镜用实时算法,复杂滤镜用LUT。这样既保证了性能,又保留了扩展性。

23.4 GPU加速:为什么比CPU快?

GPU加速的核心,是并行计算。CPU处理一个像素,可能需要几十条指令。而GPU一次可以处理成百上千个像素。你想想看,一个1080p的画面有200多万个像素,GPU的优势是碾压级的。

具体到Android平台,GPU加速的流程是这样的:

  1. 创建OpenGL上下文:与SurfaceView或TextureView绑定。
  2. 上传纹理:把YUV数据上传到GPU显存。
  3. 执行着色器:GPU并行处理每个像素。
  4. 渲染到屏幕:或者渲染到FBO(帧缓冲对象)做后续处理。

这里有个关键点:纹理上传的带宽。YUV数据量很大(1080p一帧约3MB),如果每帧都从CPU拷贝到GPU,带宽会成为瓶颈。我建议用PBO(像素缓冲对象)来异步上传,或者直接用SurfaceTexture让解码器直接输出到GPU纹理。

核心结论:GPU加速不是万能的。如果着色器过于复杂(比如循环次数过多),或者纹理上传频繁,性能反而可能不如CPU。我的经验是:着色器指令数控制在100条以内,纹理上传频率不要超过帧率

23.5 知识体系总览

下面这张图,是我整理的本章节知识体系。你可以把它当作一个“思维导图”,快速回顾核心内容:

OpenGL ES后处理 YUV转RGB着色器 BT.601 / BT.709 / BT.2020 三纹理采样 + 矩阵运算 色彩增强 对比度 + 饱和度 线性变换 / HSL转换 滤镜效果 LUT方案 / 实时算法 复古、黑白、暖色调 GPU加速原理与优化 并行计算:一次处理成百上千像素 纹理上传:PBO / SurfaceTexture 着色器指令数控制在100条以内 性能与画质的平衡

23.6 避坑指南:我踩过的三个坑

最后,分享几个我实际项目中遇到的坑,希望能帮你少走弯路。

  • 纹理坐标翻转:Android的纹理坐标系和OpenGL标准坐标系是上下颠倒的。如果不做翻转,画面会倒立。我习惯在顶点着色器里直接处理:vTexCoord = vec2(aTexCoord.x, 1.0 - aTexCoord.y);
  • YUV数据对齐:有些解码器输出的YUV数据,每行像素数不是4的倍数。如果直接当纹理上传,会出现“条纹”现象。解决办法是按行对齐,或者用glPixelStorei设置对齐方式。
  • 多线程渲染:OpenGL上下文不是线程安全的。如果解码线程和渲染线程共用同一个上下文,会崩溃。我的做法是每个线程创建独立的上下文,或者用eglMakeCurrent切换。
一个小技巧:调试着色器时,可以用gl_FragColor = vec4(vTexCoord, 0.0, 1.0);来输出纹理坐标。如果画面出现渐变,说明纹理坐标传递正确。如果画面是纯色,说明坐标有问题。

嗯,以上就是本章的全部内容。YUV转RGB、色彩增强、滤镜效果、GPU加速,这四个点环环相扣。掌握了它们,你就能在Android上做出流畅、漂亮的视频渲染效果。


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