13、OpenGL ES渲染视频:将视频帧渲染到OpenGL纹理、实现滤镜效果(黑白、怀旧)

各位同学,欢迎来到第13章。前面我们讲了怎么用MediaCodec解码,怎么把数据塞给SurfaceView。但说实话,SurfaceView能做的事太有限了——你没法在它上面加滤镜,没法做特效,没法搞那些酷炫的转场。那怎么办?

答案就是:把视频帧交给OpenGL ES。让GPU来接管渲染。

我个人习惯把OpenGL ES比作一个画板。你解码出来的每一帧画面,就是一张贴纸。你可以把贴纸贴在画板上,也可以在上面涂颜色、做扭曲、加滤镜。说白了,一切皆纹理。

13.1 为什么非要用OpenGL ES?

你可能会问:我直接用SurfaceView渲染不香吗?嗯,香是香,但不够灵活。SurfaceView背后是一个独立的窗口,你没法在它上面叠加其他图形。而OpenGL ES不一样——它让你完全控制每一个像素。

我在项目中遇到过这样一个需求:用户希望在看视频的同时,画面边缘有一圈模糊的晕影效果。用SurfaceView?做不到。用OpenGL ES?十分钟搞定。

核心思路:解码器输出一帧YUV数据,我们把它上传到OpenGL纹理,然后通过自定义着色器(Shader)来控制每个像素的最终颜色。

13.2 视频帧如何变成纹理?

这一步很关键。解码器出来的数据通常是YUV格式,而OpenGL吃的是RGB纹理。所以我们需要做一次转换。

我常用的做法是:

  1. 从MediaCodec拿到SurfaceTexture(或者直接拿ByteBuffer)
  2. 把YUV数据上传到OpenGL纹理
  3. 在片段着色器里做YUV到RGB的转换

这里贴一段核心代码,展示如何把YUV帧绑定到纹理:

// 创建OpenGL纹理
GLuint textureId;
glGenTextures(1, &textureId);
glBindTexture(GL_TEXTURE_2D, textureId);

// 设置纹理参数
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);

// 上传YUV数据(这里假设你已经有了Y、U、V三个平面的数据)
glTexImage2D(GL_TEXTURE_2D, 0, GL_LUMINANCE, width, height, 0,
             GL_LUMINANCE, GL_UNSIGNED_BYTE, yPlane);
// 注意:实际项目中需要分别上传U、V平面,或者使用GL_RGBA格式统一上传

小技巧:如果你用的是SurfaceTexture,可以直接调用updateTexImage()来更新纹理,省去手动上传的麻烦。但要注意线程同步问题。

13.3 着色器:滤镜的灵魂

纹理上传好了,接下来就是着色器的事了。OpenGL ES的渲染管线里,片段着色器(Fragment Shader)负责决定每个像素最终显示什么颜色。

说白了,滤镜就是改颜色。黑白滤镜就是把RGB转成灰度值,怀旧滤镜就是加一层棕色调。

13.3.1 黑白滤镜

黑白滤镜的原理很简单:把每个像素的R、G、B三个通道加权平均,得到一个灰度值,然后把这个灰度值赋给R、G、B三个通道。

// 片段着色器 - 黑白滤镜
precision mediump float;
varying vec2 vTexCoord;
uniform sampler2D uTexture;

void main() {
    vec4 color = texture2D(uTexture, vTexCoord);
    float gray = 0.299 * color.r + 0.587 * color.g + 0.114 * color.b;
    gl_FragColor = vec4(gray, gray, gray, color.a);
}

注意这里的权重系数。0.299、0.587、0.114是标准的亮度计算公式。我曾经见过有人直接用三个通道取平均,结果画面发灰,暗部细节全丢了。嗯,这里要注意。

13.3.2 怀旧滤镜

怀旧滤镜稍微复杂一点。它本质上是在灰度图的基础上,叠加一层棕色调。我习惯的做法是:先转灰度,再对RGB通道分别做偏移。

// 片段着色器 - 怀旧滤镜
precision mediump float;
varying vec2 vTexCoord;
uniform sampler2D uTexture;

void main() {
    vec4 color = texture2D(uTexture, vTexCoord);
    float gray = 0.299 * color.r + 0.587 * color.g + 0.114 * color.b;

    float r = gray * 1.2;
    float g = gray * 0.9;
    float b = gray * 0.5;

    gl_FragColor = vec4(clamp(r, 0.0, 1.0),
                        clamp(g, 0.0, 1.0),
                        clamp(b, 0.0, 1.0),
                        color.a);
}

你想想看,为什么R通道乘1.2,B通道乘0.5?因为怀旧色调偏暖黄,红色多一点,蓝色少一点。这个比例你可以自己调,调出你喜欢的复古感。

避坑指南:我曾经在项目里直接用了乘系数不加clamp,结果高亮区域红色溢出,画面一片惨白。记得一定要用clamp把值限制在0.0到1.0之间。

13.4 整体渲染流程

把上面这些串起来,整个渲染流程是这样的:

MediaCodec解码 YUV → OpenGL纹理 (SurfaceTexture/ByteBuffer) 片段着色器处理 (黑白/怀旧滤镜) 滤镜切换逻辑(运行时) 根据用户选择,动态替换片段着色器源码 渲染循环(每帧执行) 1. 从MediaCodec获取新帧 2. 更新纹理(updateTexImage / glTexImage2D) 3. 绑定当前滤镜着色器 → 绘制全屏四边形 → 交换缓冲区 循环

这个图你看懂了吗?核心就是:解码 → 纹理上传 → 着色器处理 → 输出。滤镜切换发生在着色器这一步,我们可以在运行时动态替换片段着色器的源码。

13.5 性能优化:别让GPU闲着

在实际项目中,性能是个大问题。尤其是低端机,GPU能力有限。我踩过几个坑,分享给你:

  • 纹理尺寸要对齐:OpenGL ES要求纹理宽高是2的幂次?那是老黄历了。现在大部分设备支持非2幂次纹理,但最好还是保持16字节对齐,否则会有性能损失。
  • 减少纹理上传次数:每次glTexImage2D都很贵。如果帧率是30fps,一秒就要上传30次。我建议用PBO(Pixel Buffer Object)来异步上传,或者直接用SurfaceTexture的零拷贝机制。
  • 着色器编译要缓存:每次切换滤镜都重新编译着色器?太慢了。我习惯在初始化时把所有滤镜的着色器都编译好,运行时直接切换program对象。

我的经验:在小米6上测试,用SurfaceTexture方式比手动上传ByteBuffer快了将近3倍。能走零拷贝就别走内存拷贝。

13.6 完整代码结构

最后,给你一个完整的代码骨架。实际项目中,我会把渲染器封装成一个类:

public class VideoRenderer {
    private int program;          // 当前滤镜的着色器程序
    private int textureId;        // 视频帧纹理
    private int vbo;              // 顶点缓冲区
    private int ebo;              // 索引缓冲区

    // 初始化:创建纹理、编译着色器、设置顶点数据
    public void init() { ... }

    // 渲染一帧:绑定纹理、使用着色器、绘制
    public void renderFrame(MediaCodec codec) {
        // 1. 更新纹理
        // 2. 使用当前滤镜的program
        // 3. 绘制全屏四边形
        // 4. 交换缓冲区
    }

    // 切换滤镜:传入滤镜类型,切换program
    public void switchFilter(FilterType type) {
        // 根据type选择对应的片段着色器源码
        // 重新编译链接program
    }
}

嗯,到这里,你已经掌握了OpenGL ES渲染视频帧的核心技术。黑白滤镜和怀旧滤镜只是开胃菜,你完全可以自己写出更多效果——比如马赛克、边缘检测、甚至人脸美颜。原理都一样:控制每个像素的颜色。

记住一句话:在GPU面前,没有什么是改不了颜色的。如果有,那就再写一个着色器。

本章要点回顾:

  • 视频帧通过纹理上传到OpenGL ES
  • 片段着色器控制每个像素的最终颜色
  • 黑白滤镜 = 加权平均转灰度
  • 怀旧滤镜 = 灰度 + 色调偏移
  • 性能优化:零拷贝纹理、着色器缓存、PBO异步上传

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